@aa2246740/dsh-resume 0.2.6 → 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.
- package/README.en.md +11 -9
- package/README.md +11 -29
- package/lib/dsh-resume.js +19 -3
- package/lib/types/dsh-resume.d.ts +1 -1
- package/lib/types/reader.d.ts +1 -1
- package/lib/types/skills.d.ts +13 -1
- package/package.json +3 -6
- package/resources/dsh_session_reader.py +1174 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Extend Grok's foreign-session reader with inert Grok, Pi, and
|
|
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
|
|