@aa2246740/dsh-resume 0.2.5 → 0.2.7

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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Extend Grok's foreign-session reader with inert Grok, Pi, and ZCode adapters."""
2
+ """Extend Grok's foreign-session reader with inert Grok, Pi, ZCode, Qoder, and WorkBuddy adapters."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
@@ -25,7 +25,7 @@ for _stream in (sys.stdout, sys.stderr):
25
25
  pass
26
26
 
27
27
 
28
- EXTENDED_TOOLS = ("grok", "pi", "zcode")
28
+ EXTENDED_TOOLS = ("grok", "pi", "qoder", "workbuddy", "zcode")
29
29
  TOOLS = (*core.TOOLS, *EXTENDED_TOOLS)
30
30
 
31
31
 
@@ -46,6 +46,140 @@ def _same_cwd(left: Any, right: str) -> bool:
46
46
  )
47
47
 
48
48
 
49
+ # Output boundary for rendered transcripts. Foreign tool arguments and results
50
+ # are copied verbatim by the provider parsers; this pass runs after that copy.
51
+ # An Authorization value includes its scheme. Replacing only the word Bearer or
52
+ # Basic leaves the opaque credential behind, and a quoted JSON key is not a bare
53
+ # assignment. Dict keys are removed before nested values are copied out.
54
+ _MAX_JSON_REDACTION_DEPTH = 8
55
+ _SENSITIVE_LAST_PIECES = {"apikey", "authorization", "password", "secret", "token"}
56
+ _SENSITIVE_ANY_PIECES = {"authorization", "password", "secret"}
57
+ _COMPACT_KEY_SUFFIXES = (
58
+ "accesstoken",
59
+ "apikey",
60
+ "clientsecret",
61
+ "idtoken",
62
+ "refreshtoken",
63
+ )
64
+
65
+
66
+ def _key_pieces(key: str) -> list[str]:
67
+ broken = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", key)
68
+ broken = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", broken)
69
+ return [piece.lower() for piece in re.findall(r"[A-Za-z0-9]+", broken)]
70
+
71
+
72
+ def _is_sensitive_key(key: str) -> bool:
73
+ pieces = _key_pieces(key)
74
+ if not pieces:
75
+ return False
76
+ if pieces[-1] in _SENSITIVE_LAST_PIECES or pieces[-1].endswith("apikey"):
77
+ return True
78
+ if len(pieces) >= 2 and pieces[-2:] == ["api", "key"]:
79
+ return True
80
+ if any(piece in _SENSITIVE_ANY_PIECES for piece in pieces):
81
+ return True
82
+ compact = "".join(pieces)
83
+ return any(
84
+ compact == suffix or compact.endswith(suffix) for suffix in _COMPACT_KEY_SUFFIXES
85
+ )
86
+
87
+
88
+ _QUOTE = r"""(?:\\?["'])"""
89
+ _KEY_BODY = (
90
+ r"[A-Za-z0-9_.-]*?"
91
+ r"(?:access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|"
92
+ r"api[_-]?key|authorization|password|secret|token)"
93
+ )
94
+ # Bearer/Basic must be consumed with the following token. A bare \S+ stops at
95
+ # the scheme word and the credential survives the later scheme pass.
96
+ _SECRET_VALUE = (
97
+ r"(?:(?:Bearer|Basic)\s+\S+"
98
+ r"|\\?\"(?:Bearer|Basic)\s+(?:\\.|[^\"\\])*\\?\""
99
+ r"|\\?'(?:Bearer|Basic)\s+(?:\\.|[^'\\])*\\?'"
100
+ r"|\\?\"(?:\\.|[^\"\\])*\\?\""
101
+ r"|\\?'(?:\\.|[^'\\])*\\?'"
102
+ r"|\S+)"
103
+ )
104
+ _ASSIGNMENT_SECRET_RE = re.compile(
105
+ rf"(?i)((?<![A-Za-z0-9_]){_QUOTE}?({_KEY_BODY})(?![A-Za-z0-9_]){_QUOTE}?\s*[=:]\s*){_SECRET_VALUE}"
106
+ )
107
+ _SCHEME_SECRET_RE = re.compile(r"(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}")
108
+ _SK_SECRET_RE = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}")
109
+
110
+
111
+ def _assignment_redaction(match: re.Match[str]) -> str:
112
+ if not _is_sensitive_key(match.group(2)):
113
+ return match.group(0)
114
+ return match.group(1) + "[REDACTED]"
115
+
116
+
117
+ def _apply_secret_patterns(value: str) -> str:
118
+ value = _ASSIGNMENT_SECRET_RE.sub(_assignment_redaction, value)
119
+ value = _SCHEME_SECRET_RE.sub(r"\1 [REDACTED]", value)
120
+ return _SK_SECRET_RE.sub("[REDACTED]", value)
121
+
122
+
123
+ def _parse_json_container(value: str) -> Any | None:
124
+ stripped = value.strip()
125
+ if len(stripped) < 2 or stripped[0] not in "{[":
126
+ return None
127
+ try:
128
+ parsed = json.loads(stripped)
129
+ except json.JSONDecodeError:
130
+ return None
131
+ if isinstance(parsed, (dict, list)):
132
+ return parsed
133
+ return None
134
+
135
+
136
+ def _redact_string(value: str, depth: int) -> str:
137
+ if depth < _MAX_JSON_REDACTION_DEPTH:
138
+ parsed = _parse_json_container(value)
139
+ if parsed is not None:
140
+ redacted = _redact_output(parsed, depth + 1)
141
+ if redacted != parsed:
142
+ try:
143
+ return json.dumps(redacted, ensure_ascii=False)
144
+ except (TypeError, ValueError):
145
+ pass
146
+ return _apply_secret_patterns(value)
147
+
148
+
149
+ def _redact_text(value: str) -> str:
150
+ return _redact_string(value, 0)
151
+
152
+
153
+ def _redact_output(value: Any, depth: int = 0) -> Any:
154
+ if isinstance(value, str):
155
+ return _redact_string(value, depth)
156
+ if isinstance(value, list):
157
+ return [_redact_output(item, depth) for item in value]
158
+ if isinstance(value, dict):
159
+ redacted: dict[Any, Any] = {}
160
+ for key, item in value.items():
161
+ if isinstance(key, str) and _is_sensitive_key(key):
162
+ redacted[key] = "[REDACTED]"
163
+ else:
164
+ redacted[key] = _redact_output(item, depth)
165
+ return redacted
166
+ return value
167
+
168
+
169
+ def _split_typed_records(
170
+ records: list[dict[str, Any]],
171
+ ) -> tuple[list[dict[str, Any]], int]:
172
+ """Drop records whose type cannot be used in set membership checks."""
173
+ kept: list[dict[str, Any]] = []
174
+ corrupt = 0
175
+ for record in records:
176
+ if isinstance(record.get("type"), str):
177
+ kept.append(record)
178
+ else:
179
+ corrupt += 1
180
+ return kept, corrupt
181
+
182
+
49
183
  def _read_json(path: Path) -> dict[str, Any]:
50
184
  try:
51
185
  value = json.loads(path.read_text(encoding="utf-8", errors="replace"))
@@ -1739,9 +1873,1047 @@ class ZCodeAdapter(ProviderAdapter):
1739
1873
  return self._read_sqlite(candidate, max_tool_chars)
1740
1874
 
1741
1875
 
1876
+ _QODER_INFORMATIONAL_TYPES = {
1877
+ "runtime-config",
1878
+ "file-history-snapshot",
1879
+ "last-prompt",
1880
+ # Housekeeping records emitted by qodercli: injected context listings,
1881
+ # leaf tracking, workspace metadata, and worktree state. Known and
1882
+ # intentionally not rendered — attachment content is Qoder-injected
1883
+ # context, not user/assistant turns.
1884
+ "attachment",
1885
+ "active-leaf",
1886
+ "workspace-directories",
1887
+ "worktree-state",
1888
+ }
1889
+ _QODER_KNOWN_TYPES = set(core.CLAUDE_KNOWN_TYPES) | _QODER_INFORMATIONAL_TYPES
1890
+
1891
+ _QODER_MESSAGE_TYPES = {"user", "assistant", "system"}
1892
+
1893
+
1894
+ def _qoder_message_eligible(record: dict[str, Any]) -> bool:
1895
+ record_type = record.get("type")
1896
+ return (
1897
+ isinstance(record_type, str)
1898
+ and record_type in _QODER_MESSAGE_TYPES
1899
+ and not record.get("isSidechain")
1900
+ and isinstance(record.get("uuid"), str)
1901
+ and bool(record.get("uuid"))
1902
+ )
1903
+
1904
+
1905
+ def _repair_qoder_parents(records: list[dict[str, Any]]) -> None:
1906
+ """Repoint message parent links that cross non-message records.
1907
+
1908
+ qodercli interleaves housekeeping records (``attachment``, ``active-leaf``,
1909
+ ...) inside the parentUuid chain, so an assistant's parentUuid can point at
1910
+ a record that is filtered out before rendering. Walk each message record's
1911
+ chain through those non-message records to the nearest message ancestor so
1912
+ no turns (notably the opening user prompt) are orphaned.
1913
+ """
1914
+ by_uuid = {
1915
+ record["uuid"]: record
1916
+ for record in records
1917
+ if isinstance(record.get("uuid"), str) and record["uuid"]
1918
+ }
1919
+ for record in records:
1920
+ if not _qoder_message_eligible(record):
1921
+ continue
1922
+ parent = core._claude_parent(record)
1923
+ if parent is None or parent not in by_uuid:
1924
+ continue
1925
+ seen: set[str] = {str(record.get("uuid"))}
1926
+ resolved = parent
1927
+ while resolved is not None and resolved in by_uuid:
1928
+ target = by_uuid[resolved]
1929
+ if _qoder_message_eligible(target):
1930
+ break
1931
+ if resolved in seen:
1932
+ resolved = None
1933
+ break
1934
+ seen.add(resolved)
1935
+ resolved = core._claude_parent(target)
1936
+ if resolved != parent:
1937
+ core._set_claude_parent(record, resolved)
1938
+
1939
+
1940
+ def _qoder_workspace_dir(records: list[dict[str, Any]]) -> str | None:
1941
+ for record in records:
1942
+ if record.get("type") != "workspace-directories":
1943
+ continue
1944
+ directories = record.get("directories")
1945
+ if isinstance(directories, list):
1946
+ value = next(
1947
+ (
1948
+ item
1949
+ for item in directories
1950
+ if isinstance(item, str) and item.strip()
1951
+ ),
1952
+ None,
1953
+ )
1954
+ if value:
1955
+ return value
1956
+ return None
1957
+
1958
+
1959
+ def _qoder_ide_roots() -> list[Path]:
1960
+ """Qoder IDE SharedClientCache transcript roots (flat layout, same JSONL schema)."""
1961
+ configured = os.environ.get("QODER_IDE_CACHE_DIR")
1962
+ if configured:
1963
+ return [Path(configured).expanduser()]
1964
+ home = Path.home()
1965
+ candidates = [
1966
+ home / "Library/Application Support/Qoder/SharedClientCache/cli/projects",
1967
+ home / ".config/Qoder/SharedClientCache/cli/projects",
1968
+ ]
1969
+ appdata = os.environ.get("APPDATA")
1970
+ if appdata:
1971
+ candidates.append(Path(appdata) / "Qoder/SharedClientCache/cli/projects")
1972
+ output: list[Path] = []
1973
+ for path in candidates:
1974
+ if path not in output:
1975
+ output.append(path)
1976
+ return output
1977
+
1978
+
1979
+ class QoderAdapter(ProviderAdapter):
1980
+ """Read Qoder's Claude-Code-shaped JSONL transcripts as inert history.
1981
+
1982
+ Primary store: ``$QODER_CONFIG_DIR/projects/<slug>/<session-id>.jsonl``
1983
+ (default ``~/.qoder``; ``QODER_HOME`` is accepted as a fallback alias).
1984
+ Qoder IDE ``SharedClientCache/cli/projects`` transcripts are a second,
1985
+ flat-layout root (overridable with ``QODER_IDE_CACHE_DIR``). Every record
1986
+ carries ``cwd``/``gitBranch``; ``isSidechain`` subagent records and
1987
+ informational ``runtime-config``/``file-history-snapshot``/``last-prompt``
1988
+ records are skipped. The encrypted ``<session-id>/state.json`` siblings,
1989
+ ``logs/sessions`` run logs, ``.auth`` credentials, and ``settings.json``
1990
+ are never read.
1991
+ """
1992
+
1993
+ tool = "qoder"
1994
+
1995
+ @staticmethod
1996
+ def _home() -> Path:
1997
+ configured = os.environ.get("QODER_CONFIG_DIR") or os.environ.get("QODER_HOME")
1998
+ return Path(configured).expanduser() if configured else Path.home() / ".qoder"
1999
+
2000
+ @classmethod
2001
+ def _projects_root(cls) -> Path:
2002
+ return cls._home() / "projects"
2003
+
2004
+ def _iter_transcripts(self) -> Iterable[tuple[Path, str]]:
2005
+ root = self._projects_root()
2006
+ if root.is_dir() and not root.is_symlink():
2007
+ try:
2008
+ buckets = sorted(root.iterdir(), key=lambda item: item.name)
2009
+ except OSError:
2010
+ buckets = []
2011
+ for bucket in buckets:
2012
+ if not bucket.is_dir() or bucket.is_symlink():
2013
+ continue
2014
+ try:
2015
+ children = sorted(bucket.iterdir(), key=lambda item: item.name)
2016
+ except OSError:
2017
+ continue
2018
+ for child in children:
2019
+ if child.is_file() and not child.is_symlink() and child.suffix == ".jsonl":
2020
+ yield child, "qoder-cli"
2021
+ for ide_root in _qoder_ide_roots():
2022
+ if not ide_root.is_dir() or ide_root.is_symlink():
2023
+ continue
2024
+ try:
2025
+ children = sorted(ide_root.iterdir(), key=lambda item: item.name)
2026
+ except OSError:
2027
+ continue
2028
+ for child in children:
2029
+ if child.is_file() and not child.is_symlink() and child.suffix == ".jsonl":
2030
+ yield child, "qoder-ide-cache"
2031
+
2032
+ def _file_candidate(
2033
+ self,
2034
+ path: Path,
2035
+ source: str,
2036
+ cwd: str,
2037
+ *,
2038
+ require_cwd: bool,
2039
+ slug_matches: bool = False,
2040
+ ) -> dict[str, Any] | None:
2041
+ try:
2042
+ records, _malformed = core._read_plain_jsonl(path)
2043
+ except core.ReaderError as exc:
2044
+ core._add_warning(
2045
+ self.warnings,
2046
+ "transcript_unreadable",
2047
+ f"Skipped unreadable Qoder transcript {path}: {exc}",
2048
+ )
2049
+ return None
2050
+ session_id: str | None = None
2051
+ actual_cwd: str | None = None
2052
+ branch: str | None = None
2053
+ has_turns = False
2054
+ timestamps: list[int] = []
2055
+ for record in records:
2056
+ if session_id is None:
2057
+ value = record.get("sessionId")
2058
+ if isinstance(value, str) and value.strip():
2059
+ session_id = value
2060
+ if actual_cwd is None:
2061
+ value = record.get("cwd")
2062
+ if isinstance(value, str) and value.strip():
2063
+ actual_cwd = value
2064
+ value = record.get("gitBranch")
2065
+ if isinstance(value, str) and value.strip():
2066
+ branch = value
2067
+ record_type = record.get("type")
2068
+ if (
2069
+ isinstance(record_type, str)
2070
+ and record_type in {"user", "assistant"}
2071
+ and isinstance(record.get("message"), dict)
2072
+ ):
2073
+ has_turns = True
2074
+ stamp = core._timestamp_to_millis(record.get("timestamp"))
2075
+ if stamp is not None:
2076
+ timestamps.append(stamp)
2077
+ if actual_cwd is None:
2078
+ actual_cwd = _qoder_workspace_dir(records)
2079
+ if not has_turns:
2080
+ return None
2081
+ # A directory slug is only a fallback for transcripts that never
2082
+ # recorded a cwd. project-a and project_a share a slug, so an explicit
2083
+ # cwd must match the requested workspace on its own.
2084
+ recorded_cwd = actual_cwd.strip() if isinstance(actual_cwd, str) else ""
2085
+ if require_cwd:
2086
+ if not recorded_cwd:
2087
+ if not slug_matches:
2088
+ return None
2089
+ actual_cwd = cwd
2090
+ elif not _same_cwd(recorded_cwd, cwd):
2091
+ return None
2092
+ if session_id is None:
2093
+ session_id = path.stem
2094
+ title = core._claude_title(records, [])
2095
+ if title is None:
2096
+ title = next(
2097
+ (
2098
+ core._one_line(text, 200)
2099
+ for record in records
2100
+ if record.get("type") == "user" and not record.get("isSidechain")
2101
+ for text in [
2102
+ core._content_text(
2103
+ (record.get("message") or {}).get("content")
2104
+ if isinstance(record.get("message"), dict)
2105
+ else None
2106
+ )
2107
+ ]
2108
+ if text.strip()
2109
+ ),
2110
+ None,
2111
+ )
2112
+ updated_ms = timestamps[-1] if timestamps else core._mtime_millis(path)
2113
+ return {
2114
+ "tool": "qoder",
2115
+ "source": source,
2116
+ "store": "projects" if source == "qoder-cli" else "ide-cache",
2117
+ "session_id": core._safe_text(session_id),
2118
+ "path": str(path),
2119
+ "title": core._safe_text(title) if title else None,
2120
+ "cwd": core._safe_text(actual_cwd) if actual_cwd else None,
2121
+ "branch": core._safe_text(branch) if branch else None,
2122
+ "created_at": core._iso_from_millis(timestamps[0] if timestamps else None),
2123
+ "updated_at_ms": updated_ms,
2124
+ "updated_at": core._iso_from_millis(updated_ms),
2125
+ "source_repo_root_path": None,
2126
+ }
2127
+
2128
+ def discover(self, cwd: str, within_min: int) -> list[dict[str, Any]]:
2129
+ self.warnings = []
2130
+ expected_slug = core.slugify(cwd)
2131
+ sessions: list[dict[str, Any]] = []
2132
+ for path, source in self._iter_transcripts():
2133
+ slug_matches = source == "qoder-cli" and path.parent.name == expected_slug
2134
+ candidate = self._file_candidate(
2135
+ path, source, cwd, require_cwd=True, slug_matches=slug_matches
2136
+ )
2137
+ if candidate is None:
2138
+ continue
2139
+ if core._within(int(candidate.get("updated_at_ms") or 0), within_min):
2140
+ sessions.append(candidate)
2141
+ return core._sort_and_dedupe(sessions)
2142
+
2143
+ def candidate_from_path(self, raw_path: str, cwd: str) -> dict[str, Any] | None:
2144
+ path = Path(raw_path).expanduser()
2145
+ if not path.is_file() or path.is_symlink() or path.suffix != ".jsonl":
2146
+ return None
2147
+ ide = any(path.parent == root for root in _qoder_ide_roots())
2148
+ candidate = self._file_candidate(
2149
+ path, "qoder-ide-cache" if ide else "qoder-cli", cwd, require_cwd=False
2150
+ )
2151
+ if candidate is not None:
2152
+ candidate["requested_cwd"] = cwd
2153
+ return candidate
2154
+
2155
+ def find_id(self, session_id: str, cwd: str) -> dict[str, Any] | None:
2156
+ wanted = session_id.casefold()
2157
+ matches: list[dict[str, Any]] = []
2158
+ deferred: list[tuple[Path, str]] = []
2159
+ for path, source in self._iter_transcripts():
2160
+ if path.stem.casefold() == wanted:
2161
+ candidate = self._file_candidate(path, source, cwd, require_cwd=False)
2162
+ if candidate is not None:
2163
+ candidate["requested_cwd"] = cwd
2164
+ matches.append(candidate)
2165
+ else:
2166
+ deferred.append((path, source))
2167
+ if not matches:
2168
+ for path, source in deferred:
2169
+ candidate = self._file_candidate(path, source, cwd, require_cwd=False)
2170
+ if candidate is None:
2171
+ continue
2172
+ if str(candidate.get("session_id", "")).casefold() == wanted:
2173
+ candidate["requested_cwd"] = cwd
2174
+ matches.append(candidate)
2175
+ if len(matches) == 1:
2176
+ return matches[0]
2177
+ if len(matches) > 1:
2178
+ raise core.AmbiguousReference(session_id, matches)
2179
+ return None
2180
+
2181
+ def read(self, candidate: dict[str, Any], max_tool_chars: int) -> dict[str, Any]:
2182
+ path = Path(str(candidate["path"])).expanduser()
2183
+ records, malformed = core._read_plain_jsonl(path)
2184
+ warnings: list[dict[str, str]] = []
2185
+ records, corrupt = _split_typed_records(records)
2186
+ if corrupt:
2187
+ core._add_warning(
2188
+ warnings,
2189
+ "corrupt_records_skipped",
2190
+ f"Skipped {corrupt} Qoder record(s) whose type was not a string.",
2191
+ )
2192
+ if malformed:
2193
+ core._add_warning(
2194
+ warnings,
2195
+ "malformed_records_skipped",
2196
+ f"Skipped {malformed} malformed Qoder transcript record(s).",
2197
+ )
2198
+ unknown = sum(
2199
+ 1
2200
+ for record in records
2201
+ if isinstance(record.get("type"), str) and record.get("type") not in _QODER_KNOWN_TYPES
2202
+ )
2203
+ if unknown:
2204
+ core._add_warning(
2205
+ warnings,
2206
+ "unknown_records_skipped",
2207
+ f"Skipped {unknown} unknown Qoder record(s) without interpreting their payloads.",
2208
+ )
2209
+ sidechain = sum(1 for record in records if record.get("isSidechain"))
2210
+ if sidechain:
2211
+ core._add_warning(
2212
+ warnings,
2213
+ "sidechain_records_skipped",
2214
+ f"Skipped {sidechain} Qoder subagent (isSidechain) record(s).",
2215
+ )
2216
+ _repair_qoder_parents(records)
2217
+ messages = core._prepare_claude_messages(records, warnings)
2218
+ leaf = core._claude_leaf(messages, warnings)
2219
+ chain: list[dict[str, Any]] = []
2220
+ if leaf is not None:
2221
+ chain, _seen = core._claude_chain(messages, leaf, warnings)
2222
+ replacements = core._claude_replacement_ids(records)
2223
+ turns = [
2224
+ turn
2225
+ for record in chain
2226
+ for turn in [core._render_claude_record(record, max_tool_chars, replacements)]
2227
+ if turn is not None
2228
+ ]
2229
+ metadata_records = chain if chain else records
2230
+ cwd = next(
2231
+ (
2232
+ record.get("cwd")
2233
+ for record in metadata_records
2234
+ if isinstance(record.get("cwd"), str) and record["cwd"].strip()
2235
+ ),
2236
+ None,
2237
+ ) or _qoder_workspace_dir(records) or (
2238
+ candidate.get("cwd") if isinstance(candidate.get("cwd"), str) else None
2239
+ )
2240
+ requested = candidate.get("requested_cwd")
2241
+ if (
2242
+ isinstance(requested, str)
2243
+ and requested
2244
+ and isinstance(cwd, str)
2245
+ and not _same_cwd(cwd, requested)
2246
+ ):
2247
+ core._add_warning(
2248
+ warnings,
2249
+ "foreign_cwd_mismatch",
2250
+ f"Qoder session cwd {cwd} differs from the current workspace {requested}.",
2251
+ )
2252
+ branch = next(
2253
+ (
2254
+ record.get("gitBranch")
2255
+ for record in reversed(metadata_records)
2256
+ if isinstance(record.get("gitBranch"), str) and record["gitBranch"].strip()
2257
+ ),
2258
+ None,
2259
+ ) or (candidate.get("branch") if isinstance(candidate.get("branch"), str) else None)
2260
+ model = next(
2261
+ (
2262
+ value
2263
+ for record in reversed(records)
2264
+ if record.get("type") == "assistant"
2265
+ for value in [
2266
+ (record.get("message") or {}).get("model")
2267
+ if isinstance(record.get("message"), dict)
2268
+ else None
2269
+ ]
2270
+ if isinstance(value, str) and value.strip()
2271
+ ),
2272
+ None,
2273
+ )
2274
+ timestamps = [
2275
+ stamp
2276
+ for record in chain
2277
+ for stamp in [core._timestamp_to_millis(record.get("timestamp"))]
2278
+ if stamp is not None
2279
+ ]
2280
+ title = candidate.get("title") or core._claude_title(records, turns)
2281
+ session_id = candidate.get("session_id") or path.stem
2282
+ result = {
2283
+ "tool": "qoder",
2284
+ "source": str(candidate.get("source") or "qoder-cli"),
2285
+ "session_id": core._safe_text(session_id),
2286
+ "path": str(path),
2287
+ "title": title,
2288
+ "cwd": cwd,
2289
+ "branch": branch,
2290
+ "created_at": (
2291
+ core._iso_from_millis(timestamps[0])
2292
+ if timestamps
2293
+ else candidate.get("created_at")
2294
+ ),
2295
+ "updated_at": (
2296
+ core._iso_from_millis(timestamps[-1])
2297
+ if timestamps
2298
+ else candidate.get("updated_at") or core._iso_from_millis(core._mtime_millis(path))
2299
+ ),
2300
+ "source_repo_root_path": None,
2301
+ "model": core._safe_text(model) if model else None,
2302
+ "turns": turns,
2303
+ "warnings": warnings,
2304
+ }
2305
+ return _redact_output(core._finalize_result(result))
2306
+
2307
+
2308
+ _WORKBUDDY_CUSTOM_ITEM_TYPES = {
2309
+ "custom-title",
2310
+ "ai-title",
2311
+ "file-history-snapshot",
2312
+ "summary",
2313
+ "topic",
2314
+ "goal-result",
2315
+ "goal-progress",
2316
+ "resend-fork-notice",
2317
+ }
2318
+
2319
+ _WORKBUDDY_USER_QUERY_RE = re.compile(r"<user_query>([\s\S]*?)</user_query>")
2320
+ _WORKBUDDY_SYSTEM_TAG_RES = [
2321
+ re.compile(rf"<{tag}\b[^>]*>[\s\S]*?</{tag}>\s*")
2322
+ for tag in (
2323
+ "system-reminder",
2324
+ "additional_data",
2325
+ "user_info",
2326
+ "cb_summary",
2327
+ "rules",
2328
+ "content_policy",
2329
+ "agent_skills",
2330
+ "always_applied_workspace_rules",
2331
+ "agent_requestable_workspace_rules",
2332
+ "project_context",
2333
+ "communication",
2334
+ "tool_calling",
2335
+ "making_code_changes",
2336
+ "response_language",
2337
+ "git_status",
2338
+ )
2339
+ ]
2340
+ _WORKBUDDY_TEAMMATE_RE = re.compile(
2341
+ r"^\s*<teammate-message\b[^>]*>[\s\S]*</teammate-message>\s*$",
2342
+ re.IGNORECASE,
2343
+ )
2344
+
2345
+
2346
+ def _workbuddy_int32(value: int) -> int:
2347
+ value &= 0xFFFFFFFF
2348
+ return value - 0x100000000 if value >= 0x80000000 else value
2349
+
2350
+
2351
+ def _workbuddy_djb2_base36(value: str) -> str:
2352
+ """WorkBuddy's djb2 over UTF-8 bytes rendered as base36."""
2353
+ state = 5381
2354
+ for byte in value.encode("utf-8"):
2355
+ state = _workbuddy_int32(state * 33) ^ byte
2356
+ state &= 0xFFFFFFFF
2357
+ digits = "0123456789abcdefghijklmnopqrstuvwxyz"
2358
+ if state == 0:
2359
+ return "0"
2360
+ output = ""
2361
+ while state:
2362
+ state, remainder = divmod(state, 36)
2363
+ output = digits[remainder] + output
2364
+ return output
2365
+
2366
+
2367
+ def _workbuddy_truncate_utf8(value: str, max_bytes: int) -> str:
2368
+ encoded = value.encode("utf-8")
2369
+ if len(encoded) <= max_bytes:
2370
+ return value
2371
+ return encoded[:max_bytes].decode("utf-8", errors="ignore")
2372
+
2373
+
2374
+ def _workbuddy_project_slug(value: str) -> str:
2375
+ """compressWorkspacePathName: / \\ : -> -, trim, collapse, hash long paths."""
2376
+ base = re.sub(r"[/\\:]", "-", value)
2377
+ base = re.sub(r"-+", "-", base.strip("-"))
2378
+ if len(base.encode("utf-8")) <= 255:
2379
+ return base
2380
+ return f"{_workbuddy_truncate_utf8(base, 180)}-{_workbuddy_djb2_base36(base)}"
2381
+
2382
+
2383
+ def _workbuddy_teammate(item: dict[str, Any]) -> bool:
2384
+ provider = item.get("providerData")
2385
+ if isinstance(provider, dict):
2386
+ teammate = provider.get("teammateMessage")
2387
+ if isinstance(teammate, dict) and isinstance(teammate.get("from"), str):
2388
+ return True
2389
+ text = _workbuddy_content_text(item.get("content"))
2390
+ return bool(text and _WORKBUDDY_TEAMMATE_RE.match(text))
2391
+
2392
+
2393
+ def _workbuddy_meta_record(item: dict[str, Any]) -> bool:
2394
+ """Records the WorkBuddy app never shows as real user/assistant turns."""
2395
+ provider = item.get("providerData")
2396
+ provider = provider if isinstance(provider, dict) else {}
2397
+ if (
2398
+ provider.get("isMeta") is True
2399
+ or provider.get("isCompactInternal") is True
2400
+ or isinstance(provider.get("compactType"), str)
2401
+ or _workbuddy_teammate(item)
2402
+ ):
2403
+ return True
2404
+ if item.get("role") == "user":
2405
+ if provider.get("skipRun") is True:
2406
+ return True
2407
+ client_meta = provider.get("clientMeta")
2408
+ codebuddy = (
2409
+ client_meta.get("codebuddy.ai")
2410
+ if isinstance(client_meta, dict) and isinstance(client_meta.get("codebuddy.ai"), dict)
2411
+ else {}
2412
+ )
2413
+ if codebuddy.get("queueOrigin") == "steer":
2414
+ return True
2415
+ return False
2416
+
2417
+
2418
+ def _workbuddy_content_text(content: Any) -> str:
2419
+ parts: list[str] = []
2420
+ for block in core._blocks(content):
2421
+ text = block.get("text")
2422
+ if isinstance(text, str):
2423
+ parts.append(text)
2424
+ return "\n".join(parts)
2425
+
2426
+
2427
+ def _workbuddy_strip_system_tags(text: str) -> str:
2428
+ stripped = text
2429
+ for pattern in _WORKBUDDY_SYSTEM_TAG_RES:
2430
+ stripped = pattern.sub("", stripped)
2431
+ stripped = _WORKBUDDY_USER_QUERY_RE.sub(lambda match: match.group(1), stripped)
2432
+ return stripped.strip()
2433
+
2434
+
2435
+ def _workbuddy_user_text(content: Any) -> str:
2436
+ """Visible user text: providerData.content first, else tag-stripped input_text."""
2437
+ if isinstance(content, str):
2438
+ return _workbuddy_strip_system_tags(content)
2439
+ if not isinstance(content, list):
2440
+ return ""
2441
+ for block in content:
2442
+ if isinstance(block, str):
2443
+ extracted = _workbuddy_strip_system_tags(block)
2444
+ if extracted:
2445
+ return extracted
2446
+ continue
2447
+ if not isinstance(block, dict):
2448
+ continue
2449
+ block_type = block.get("type")
2450
+ if block_type and block_type != "input_text":
2451
+ continue
2452
+ text = block.get("text") or block.get("input_text")
2453
+ if not isinstance(text, str) or not text.strip():
2454
+ continue
2455
+ if "<image_local_path>" in text:
2456
+ continue
2457
+ if text.lstrip().startswith("<system-reminder") and "<user_query>" not in text:
2458
+ continue
2459
+ provider = block.get("providerData")
2460
+ if isinstance(provider, dict) and isinstance(provider.get("content"), str):
2461
+ provider_content = provider["content"].strip()
2462
+ if provider_content:
2463
+ return core._safe_text(provider_content)
2464
+ extracted = _workbuddy_strip_system_tags(text)
2465
+ if extracted:
2466
+ return extracted
2467
+ return ""
2468
+
2469
+
2470
+ def _workbuddy_append_turn(
2471
+ turns: list[dict[str, Any]],
2472
+ role: str,
2473
+ text: str,
2474
+ ) -> None:
2475
+ if not text:
2476
+ return
2477
+ if (
2478
+ turns
2479
+ and turns[-1].get("role") == role
2480
+ and not turns[-1].get("tool_calls")
2481
+ and not turns[-1].get("tool_results")
2482
+ ):
2483
+ turns[-1]["text"] = core._safe_text(turns[-1].get("text")) + "\n" + text
2484
+ return
2485
+ turns.append(core._turn(role, text=text))
2486
+
2487
+
2488
+ def _workbuddy_call_id(record: dict[str, Any], index: int) -> str:
2489
+ for key in ("callId", "call_id", "id"):
2490
+ value = record.get(key)
2491
+ if isinstance(value, str) and value:
2492
+ return value
2493
+ return f"workbuddy-call-{index}"
2494
+
2495
+
2496
+ def _workbuddy_result_content(record: dict[str, Any], max_tool_chars: int) -> str:
2497
+ output = record.get("output")
2498
+ if isinstance(output, str) and output.strip():
2499
+ return core._json_preview(output, max_tool_chars)
2500
+ provider = record.get("providerData")
2501
+ if isinstance(provider, dict):
2502
+ tool_result = provider.get("toolResult") or provider.get("workbuddyToolResult")
2503
+ if tool_result is not None:
2504
+ return core._json_preview(tool_result, max_tool_chars)
2505
+ return core._json_preview(output, max_tool_chars)
2506
+
2507
+
2508
+ def _workbuddy_result_is_error(record: dict[str, Any]) -> bool:
2509
+ status = record.get("status")
2510
+ if isinstance(status, str) and status in {"incomplete", "failed", "error"}:
2511
+ return True
2512
+ provider = record.get("providerData")
2513
+ if isinstance(provider, dict):
2514
+ tool_result = provider.get("toolResult")
2515
+ if isinstance(tool_result, dict) and tool_result.get("isError") is True:
2516
+ return True
2517
+ return False
2518
+
2519
+
2520
+ def _workbuddy_session_info(records: list[dict[str, Any]]) -> dict[str, Any]:
2521
+ """cwd, titles, model, and timestamps exactly like the app's JSONL scan."""
2522
+ cwd: str | None = None
2523
+ custom_title: str | None = None
2524
+ ai_title: str | None = None
2525
+ topic: str | None = None
2526
+ first_user_text: str | None = None
2527
+ model: str | None = None
2528
+ first_ts: int | None = None
2529
+ last_ts: int | None = None
2530
+ has_turn = False
2531
+ for record in records:
2532
+ timestamp = core._timestamp_to_millis(record.get("timestamp"))
2533
+ if timestamp is not None:
2534
+ if first_ts is None:
2535
+ first_ts = timestamp
2536
+ last_ts = timestamp
2537
+ if cwd is None:
2538
+ value = record.get("cwd")
2539
+ if isinstance(value, str) and value:
2540
+ cwd = value
2541
+ if model is None:
2542
+ value = record.get("model")
2543
+ if isinstance(value, str) and value:
2544
+ model = value
2545
+ record_type = record.get("type")
2546
+ if record_type == "custom-title":
2547
+ value = record.get("customTitle")
2548
+ if isinstance(value, str) and value.strip():
2549
+ custom_title = value.strip()
2550
+ elif record_type == "ai-title":
2551
+ value = record.get("aiTitle")
2552
+ if ai_title is None and isinstance(value, str) and value.strip():
2553
+ ai_title = value.strip()
2554
+ elif record_type == "topic":
2555
+ value = record.get("topic")
2556
+ if topic is None and isinstance(value, str) and value.strip():
2557
+ topic = value.strip()
2558
+ elif record_type == "message" and record.get("role") in {"user", "assistant"}:
2559
+ if _workbuddy_meta_record(record):
2560
+ continue
2561
+ has_turn = True
2562
+ if first_user_text is None and record.get("role") == "user":
2563
+ text = _workbuddy_user_text(record.get("content"))
2564
+ if text:
2565
+ first_user_text = core._one_line(text, 120)
2566
+ title = custom_title or ai_title or topic
2567
+ if not title and first_user_text:
2568
+ title = (
2569
+ first_user_text if len(first_user_text) <= 50
2570
+ else first_user_text[:50] + "..."
2571
+ )
2572
+ return {
2573
+ "cwd": cwd,
2574
+ "title": title,
2575
+ "model": model,
2576
+ "first_ts": first_ts,
2577
+ "last_ts": last_ts,
2578
+ "has_turn": has_turn,
2579
+ }
2580
+
2581
+
2582
+ class WorkBuddyAdapter(ProviderAdapter):
2583
+ """Read WorkBuddy/CodeBuddy projects JSONL as inert history.
2584
+
2585
+ Session transcripts live at ``<config>/projects/<compressedCwd>/<id>.jsonl``
2586
+ where ``<config>`` resolves like the app: ``WORKBUDDY_CONFIG_DIR`` or
2587
+ ``CODEBUDDY_CONFIG_DIR``, else ``~/.workbuddy-ai`` (this product's
2588
+ ``dataFolderName``) or the legacy ``~/.workbuddy``. ``WORKBUDDY_DATA_FOLDER_NAME``
2589
+ names a different folder under home. ``<id>.quickask`` marked files are
2590
+ quick-ask scratch sessions and are skipped. ``workbuddy.db`` is only an
2591
+ index; the JSONL transcript is authoritative.
2592
+ """
2593
+
2594
+ tool = "workbuddy"
2595
+
2596
+ @staticmethod
2597
+ def _roots() -> list[Path]:
2598
+ for variable in ("WORKBUDDY_CONFIG_DIR", "CODEBUDDY_CONFIG_DIR"):
2599
+ value = os.environ.get(variable, "").strip()
2600
+ if value:
2601
+ return [Path(value).expanduser()]
2602
+ folder = os.environ.get("WORKBUDDY_DATA_FOLDER_NAME", "").strip()
2603
+ if folder:
2604
+ return [Path.home() / folder]
2605
+ return [Path.home() / ".workbuddy-ai", Path.home() / ".workbuddy"]
2606
+
2607
+ @staticmethod
2608
+ def _project_names(cwd: str) -> list[str]:
2609
+ raw = str(Path(cwd).expanduser())
2610
+ absolute = _absolute_cwd(cwd)
2611
+ canonical = _canonical_cwd(cwd)
2612
+ return list(
2613
+ dict.fromkeys(
2614
+ _workbuddy_project_slug(value)
2615
+ for value in (raw, absolute, canonical)
2616
+ )
2617
+ )
2618
+
2619
+ @classmethod
2620
+ def _iter_jsonl(cls, projects: Path, names: Iterable[str]) -> Iterator[Path]:
2621
+ seen: set[Path] = set()
2622
+ for name in names:
2623
+ workspace = projects / name
2624
+ if not workspace.is_dir() or workspace.is_symlink():
2625
+ continue
2626
+ for path in sorted(workspace.glob("*.jsonl"), key=lambda item: item.name):
2627
+ if path.is_file() and not path.is_symlink() and path not in seen:
2628
+ seen.add(path)
2629
+ yield path
2630
+ if seen or not projects.is_dir() or projects.is_symlink():
2631
+ return
2632
+ # Slug collisions are lossy; fall back to record-level cwd matching.
2633
+ for workspace in sorted(projects.iterdir(), key=lambda item: item.name):
2634
+ if not workspace.is_dir() or workspace.is_symlink():
2635
+ continue
2636
+ for path in sorted(workspace.glob("*.jsonl"), key=lambda item: item.name):
2637
+ if path.is_file() and not path.is_symlink() and path not in seen:
2638
+ seen.add(path)
2639
+ yield path
2640
+
2641
+ @classmethod
2642
+ def _files_for_cwd(cls, cwd: str) -> Iterator[Path]:
2643
+ names = cls._project_names(cwd)
2644
+ for root in cls._roots():
2645
+ yield from cls._iter_jsonl(root / "projects", names)
2646
+
2647
+ @classmethod
2648
+ def _all_files(cls) -> Iterator[Path]:
2649
+ for root in cls._roots():
2650
+ projects = root / "projects"
2651
+ if not projects.is_dir() or projects.is_symlink():
2652
+ continue
2653
+ for workspace in sorted(projects.iterdir(), key=lambda item: item.name):
2654
+ if not workspace.is_dir() or workspace.is_symlink():
2655
+ continue
2656
+ for path in sorted(workspace.glob("*.jsonl"), key=lambda item: item.name):
2657
+ if path.is_file() and not path.is_symlink():
2658
+ yield path
2659
+
2660
+ @staticmethod
2661
+ def _is_quick_ask(path: Path) -> bool:
2662
+ marker = path.with_name(f"{path.stem}.quickask")
2663
+ try:
2664
+ return marker.exists()
2665
+ except OSError:
2666
+ return False
2667
+
2668
+ @classmethod
2669
+ def _candidate_from_file(
2670
+ cls,
2671
+ path: Path,
2672
+ cwd: str,
2673
+ *,
2674
+ require_cwd: bool,
2675
+ ) -> dict[str, Any] | None:
2676
+ if (
2677
+ not path.is_file()
2678
+ or path.is_symlink()
2679
+ or path.suffix != ".jsonl"
2680
+ or cls._is_quick_ask(path)
2681
+ ):
2682
+ return None
2683
+ try:
2684
+ records, _malformed = core._read_plain_jsonl(path)
2685
+ except core.ReaderError:
2686
+ return None
2687
+ meta = _read_json_optional(path.with_name(f"{path.stem}.meta.json")) or {}
2688
+ info = _workbuddy_session_info(records)
2689
+ if not info["has_turn"]:
2690
+ return None
2691
+ actual_cwd = info["cwd"]
2692
+ if not actual_cwd:
2693
+ meta_cwd = meta.get("cwd")
2694
+ actual_cwd = meta_cwd if isinstance(meta_cwd, str) else None
2695
+ if require_cwd and not _same_cwd(actual_cwd, cwd):
2696
+ return None
2697
+ created_ms = core._timestamp_to_millis(meta.get("createdAt")) or info["first_ts"]
2698
+ updated_ms = (
2699
+ core._timestamp_to_millis(meta.get("updatedAt"))
2700
+ or info["last_ts"]
2701
+ or core._mtime_millis(path)
2702
+ )
2703
+ model = info["model"]
2704
+ if not model:
2705
+ meta_model = meta.get("model")
2706
+ model = meta_model if isinstance(meta_model, str) else None
2707
+ return {
2708
+ "tool": "workbuddy",
2709
+ "source": "workbuddy",
2710
+ "session_id": core._safe_text(path.stem),
2711
+ "path": str(path),
2712
+ "title": info["title"],
2713
+ "cwd": core._safe_text(actual_cwd) if actual_cwd else None,
2714
+ "branch": None,
2715
+ "created_at": core._iso_from_millis(created_ms),
2716
+ "updated_at_ms": updated_ms,
2717
+ "updated_at": core._iso_from_millis(updated_ms),
2718
+ "source_repo_root_path": None,
2719
+ "model": model,
2720
+ }
2721
+
2722
+ def discover(self, cwd: str, within_min: int) -> list[dict[str, Any]]:
2723
+ self.warnings = []
2724
+ sessions: list[dict[str, Any]] = []
2725
+ for path in self._files_for_cwd(cwd):
2726
+ candidate = self._candidate_from_file(path, cwd, require_cwd=True)
2727
+ if candidate is None:
2728
+ continue
2729
+ if core._within(int(candidate.get("updated_at_ms") or 0), within_min):
2730
+ sessions.append(candidate)
2731
+ return core._sort_and_dedupe(sessions)
2732
+
2733
+ def candidate_from_path(self, raw_path: str, cwd: str) -> dict[str, Any] | None:
2734
+ path = Path(raw_path).expanduser()
2735
+ if not path.exists() or path.is_symlink():
2736
+ return None
2737
+ return self._candidate_from_file(path, cwd, require_cwd=False)
2738
+
2739
+ def find_id(self, session_id: str, cwd: str) -> dict[str, Any] | None:
2740
+ for path in self._files_for_cwd(cwd):
2741
+ if path.stem.casefold() == session_id.casefold():
2742
+ return self._candidate_from_file(path, cwd, require_cwd=False)
2743
+ for path in self._all_files():
2744
+ if path.stem.casefold() != session_id.casefold():
2745
+ continue
2746
+ return self._candidate_from_file(path, cwd, require_cwd=False)
2747
+ return None
2748
+
2749
+ def read(self, candidate: dict[str, Any], max_tool_chars: int) -> dict[str, Any]:
2750
+ path = Path(str(candidate["path"]))
2751
+ records, malformed = core._read_plain_jsonl(path)
2752
+ warnings: list[dict[str, str]] = []
2753
+ turns: list[dict[str, Any]] = []
2754
+ summaries: list[dict[str, Any]] = []
2755
+ emitted_calls: set[str] = set()
2756
+ emitted_results: set[str] = set()
2757
+ hidden_thoughts = 0
2758
+ filtered_records = 0
2759
+ unknown_records = 0
2760
+
2761
+ corrupt_records = 0
2762
+ for index, record in enumerate(records):
2763
+ record_type = record.get("type")
2764
+ if not isinstance(record_type, str):
2765
+ corrupt_records += 1
2766
+ continue
2767
+ if record_type in _WORKBUDDY_CUSTOM_ITEM_TYPES:
2768
+ if record_type == "summary":
2769
+ content = (
2770
+ record.get("summary")
2771
+ or record.get("text")
2772
+ or record.get("content")
2773
+ )
2774
+ if isinstance(content, str) and content.strip():
2775
+ summaries.append(
2776
+ {
2777
+ "kind": "summary",
2778
+ "content": core._safe_text(content),
2779
+ "inert": True,
2780
+ }
2781
+ )
2782
+ continue
2783
+ if record_type == "message":
2784
+ role = record.get("role")
2785
+ if _workbuddy_meta_record(record):
2786
+ filtered_records += 1
2787
+ continue
2788
+ if role == "user":
2789
+ text = _workbuddy_user_text(record.get("content"))
2790
+ _workbuddy_append_turn(turns, "user", text)
2791
+ elif role == "assistant":
2792
+ text = _workbuddy_assistant_text(record.get("content"))
2793
+ _workbuddy_append_turn(turns, "assistant", text)
2794
+ else:
2795
+ filtered_records += 1
2796
+ elif record_type == "reasoning":
2797
+ hidden_thoughts += 1
2798
+ elif record_type == "function_call":
2799
+ call_id = _workbuddy_call_id(record, index)
2800
+ if call_id in emitted_calls:
2801
+ continue
2802
+ emitted_calls.add(call_id)
2803
+ name = record.get("name")
2804
+ turns.append(
2805
+ core._turn(
2806
+ "assistant",
2807
+ tool_calls=[
2808
+ {
2809
+ "id": core._safe_text(call_id),
2810
+ "name": core._safe_text(name) if name else "workbuddy_tool",
2811
+ "input": core._json_preview(
2812
+ record.get("arguments") or {},
2813
+ max_tool_chars,
2814
+ ),
2815
+ "inert": True,
2816
+ }
2817
+ ],
2818
+ )
2819
+ )
2820
+ elif record_type in {"function_call_result", "function_call_output"}:
2821
+ call_id = _workbuddy_call_id(record, index)
2822
+ if call_id in emitted_results:
2823
+ continue
2824
+ emitted_results.add(call_id)
2825
+ turns.append(
2826
+ core._turn(
2827
+ "tool",
2828
+ tool_results=[
2829
+ {
2830
+ "tool_use_id": core._safe_text(call_id),
2831
+ "content": _workbuddy_result_content(
2832
+ record,
2833
+ max_tool_chars,
2834
+ ),
2835
+ "is_error": _workbuddy_result_is_error(record),
2836
+ "unavailable": False,
2837
+ "inert": True,
2838
+ }
2839
+ ],
2840
+ )
2841
+ )
2842
+ else:
2843
+ unknown_records += 1
2844
+
2845
+ if corrupt_records:
2846
+ core._add_warning(
2847
+ warnings,
2848
+ "corrupt_records_skipped",
2849
+ f"Skipped {corrupt_records} WorkBuddy record(s) whose type was not a string.",
2850
+ )
2851
+ if malformed:
2852
+ core._add_warning(
2853
+ warnings,
2854
+ "malformed_records_skipped",
2855
+ f"Skipped {malformed} malformed WorkBuddy record(s).",
2856
+ )
2857
+ if hidden_thoughts:
2858
+ core._add_warning(
2859
+ warnings,
2860
+ "hidden_reasoning_skipped",
2861
+ f"Excluded {hidden_thoughts} WorkBuddy reasoning record(s).",
2862
+ )
2863
+ if filtered_records:
2864
+ core._add_warning(
2865
+ warnings,
2866
+ "internal_records_skipped",
2867
+ f"Excluded {filtered_records} WorkBuddy internal or meta record(s).",
2868
+ )
2869
+ if hidden_thoughts or filtered_records:
2870
+ core._add_warning(
2871
+ warnings,
2872
+ "unsafe_records_skipped",
2873
+ "WorkBuddy hidden-reasoning or internal records were excluded from inert history.",
2874
+ )
2875
+ if unknown_records:
2876
+ core._add_warning(
2877
+ warnings,
2878
+ "unknown_records_skipped",
2879
+ f"Skipped {unknown_records} unsupported WorkBuddy record(s).",
2880
+ )
2881
+
2882
+ result = {
2883
+ "tool": "workbuddy",
2884
+ "source": "workbuddy",
2885
+ "session_id": candidate.get("session_id"),
2886
+ "path": str(path),
2887
+ "title": candidate.get("title"),
2888
+ "cwd": candidate.get("cwd"),
2889
+ "branch": None,
2890
+ "created_at": candidate.get("created_at"),
2891
+ "updated_at": candidate.get("updated_at"),
2892
+ "source_repo_root_path": None,
2893
+ "model": candidate.get("model"),
2894
+ "turns": turns,
2895
+ "summaries": summaries,
2896
+ "warnings": warnings,
2897
+ }
2898
+ return _redact_output(core._finalize_result(result))
2899
+
2900
+
2901
+ def _workbuddy_assistant_text(content: Any) -> str:
2902
+ parts: list[str] = []
2903
+ for block in core._blocks(content):
2904
+ block_type = block.get("type")
2905
+ if isinstance(block_type, str) and block_type in {"output_text", "text"}:
2906
+ text = block.get("text")
2907
+ if isinstance(text, str) and text:
2908
+ parts.append(text)
2909
+ return core._safe_text("\n".join(parts))
2910
+
2911
+
1742
2912
  ADAPTERS: dict[str, ProviderAdapter] = {
1743
2913
  "grok": GrokAdapter(),
1744
2914
  "pi": PiAdapter(),
2915
+ "qoder": QoderAdapter(),
2916
+ "workbuddy": WorkBuddyAdapter(),
1745
2917
  "zcode": ZCodeAdapter(),
1746
2918
  }
1747
2919