Skip to content

Commit a5d0ac5

Browse files
committed
Add last user message timestamp to search results
1 parent 18ae7f8 commit a5d0ac5

4 files changed

Lines changed: 96 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ By default, text output includes only:
3939

4040
- `Nombre`
4141
- `Fecha`
42+
- `Ultimo mensaje usuario`
4243
- `Descripcion`
4344

4445
Use `--verbose` to include local paths, scores, and match reasons.

SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ The bundled script is offline-only and should not send conversation contents out
2121
6. Return the best matches in a simple format with:
2222
- conversation name
2323
- date and time
24+
- date and time of the last user message
2425
- short description
2526

2627
## Ask Only When Needed
@@ -41,6 +42,7 @@ For each candidate, provide:
4142

4243
- `Nombre`
4344
- `Fecha`
45+
- `Ultimo mensaje usuario`
4446
- `Descripcion`
4547

4648
Include the local file path only when it materially helps disambiguate or when the user asks for it.

scripts/find_codex_conversations.py

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,19 @@ class SearchResult:
2525
session_id: str
2626
thread_name: str
2727
updated_at: str
28+
last_user_message_at: str
2829
description: str
2930
session_path: str
3031
score: int
3132
reasons: list[str]
3233

3334

35+
@dataclass
36+
class UserMessage:
37+
text: str
38+
timestamp: str
39+
40+
3441
def parse_args() -> argparse.Namespace:
3542
parser = argparse.ArgumentParser(
3643
description="Search local Codex conversations under ~/.codex."
@@ -207,8 +214,18 @@ def merge_index_and_session_files(entries: list[SessionIndexEntry], session_path
207214
return merged
208215

209216

210-
def extract_user_messages(session_path: Path) -> list[str]:
211-
messages: list[str] = []
217+
def extract_event_timestamp(event: dict[str, object], payload: dict[str, object]) -> str:
218+
timestamp = (
219+
event.get("timestamp")
220+
or payload.get("timestamp")
221+
or event.get("created_at")
222+
or payload.get("created_at")
223+
)
224+
return str(timestamp).strip() if timestamp else ""
225+
226+
227+
def extract_user_messages(session_path: Path) -> list[UserMessage]:
228+
messages: list[UserMessage] = []
212229
try:
213230
with session_path.open("r", encoding="utf-8") as handle:
214231
for line in handle:
@@ -224,7 +241,9 @@ def extract_user_messages(session_path: Path) -> list[str]:
224241
if event_type == "event_msg" and payload.get("type") in USER_MESSAGE_TYPES:
225242
text = payload.get("message", "").strip()
226243
if text:
227-
messages.append(text)
244+
messages.append(
245+
UserMessage(text=text, timestamp=extract_event_timestamp(event, payload))
246+
)
228247
continue
229248
if event_type == "response_item" and payload.get("type") == "message" and payload.get("role") == "user":
230249
contents = payload.get("content", [])
@@ -235,7 +254,12 @@ def extract_user_messages(session_path: Path) -> list[str]:
235254
if text:
236255
text_parts.append(text)
237256
if text_parts:
238-
messages.append(" ".join(text_parts))
257+
messages.append(
258+
UserMessage(
259+
text=" ".join(text_parts),
260+
timestamp=extract_event_timestamp(event, payload),
261+
)
262+
)
239263
except OSError:
240264
return []
241265
return messages
@@ -281,14 +305,14 @@ def is_noise_line(line: str) -> bool:
281305
return False
282306

283307

284-
def extract_description(messages: list[str], fallback: str) -> str:
308+
def extract_description(messages: list[UserMessage], fallback: str) -> str:
285309
for message in messages:
286-
if "AGENTS.md instructions for" in message and "## My request for Codex:" not in message:
310+
if "AGENTS.md instructions for" in message.text and "## My request for Codex:" not in message.text:
287311
continue
288-
candidates = [message]
312+
candidates = [message.text]
289313
marker = "## My request for Codex:"
290-
if marker in message:
291-
candidates.insert(0, message.split(marker, 1)[1])
314+
if marker in message.text:
315+
candidates.insert(0, message.text.split(marker, 1)[1])
292316
for candidate_block in candidates:
293317
for line in candidate_block.splitlines():
294318
if is_noise_line(line):
@@ -327,7 +351,8 @@ def score_entry(
327351
score = 0
328352
reasons: list[str] = []
329353
description = ""
330-
messages: list[str] = []
354+
messages: list[UserMessage] = []
355+
last_user_message_at = ""
331356

332357
if exact_date and entry.updated_at.startswith(exact_date.strftime("%Y-%m-%d")):
333358
score += 25
@@ -343,11 +368,17 @@ def score_entry(
343368
messages = extract_user_messages(session_path)
344369
if messages:
345370
description = extract_description(messages, entry.thread_name)
371+
last_user_message_at = messages[-1].timestamp
346372
for idx, message in enumerate(messages[:6]):
347373
for query in queries:
348374
exact_weight = 45 if idx == 0 else 20
349375
token_weight = 8 if idx == 0 else 4
350-
query_score, query_reasons = score_text_match(query, message, exact_weight=exact_weight, token_weight=token_weight)
376+
query_score, query_reasons = score_text_match(
377+
query,
378+
message.text,
379+
exact_weight=exact_weight,
380+
token_weight=token_weight,
381+
)
351382
score += query_score
352383
prefix = "first-user" if idx == 0 else "user"
353384
reasons.extend([f"{prefix}:{reason}" for reason in query_reasons])
@@ -359,6 +390,7 @@ def score_entry(
359390
session_id=entry.session_id,
360391
thread_name=entry.thread_name or "Sin nombre",
361392
updated_at=entry.updated_at,
393+
last_user_message_at=last_user_message_at,
362394
description=description,
363395
session_path=str(session_path) if session_path else "",
364396
score=score,
@@ -385,6 +417,7 @@ def render_text(results: list[SearchResult], verbose: bool = False) -> str:
385417
lines = [
386418
f"Nombre: {result.thread_name}",
387419
f"Fecha: {result.updated_at or 'Unknown'}",
420+
f"Ultimo mensaje usuario: {result.last_user_message_at or 'Unknown'}",
388421
f"Descripcion: {result.description}",
389422
]
390423
if verbose:
@@ -405,6 +438,7 @@ def render_json(results: list[SearchResult], verbose: bool = False) -> str:
405438
item: dict[str, object] = {
406439
"thread_name": result.thread_name,
407440
"updated_at": result.updated_at,
441+
"last_user_message_at": result.last_user_message_at,
408442
"description": result.description,
409443
}
410444
if verbose:

tests/test_find_codex_conversations.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def test_query_matches_accents_in_title_and_messages(self) -> None:
5353
},
5454
{
5555
"type": "event_msg",
56+
"timestamp": "2026-04-21T08:35:00Z",
5657
"payload": {"type": "user_message", "message": "Necesito reescalar imagenes RAW."},
5758
},
5859
],
@@ -62,6 +63,7 @@ def test_query_matches_accents_in_title_and_messages(self) -> None:
6263

6364
self.assertEqual(result.returncode, 0, result.stderr)
6465
payload = json.loads(result.stdout)
66+
self.assertEqual(payload[0]["last_user_message_at"], "2026-04-21T08:35:00Z")
6567
self.assertEqual(payload[0]["thread_name"], "Reescalar imágenes RAW")
6668

6769
def test_default_text_hides_path_score_and_reasons(self) -> None:
@@ -77,6 +79,7 @@ def test_default_text_hides_path_score_and_reasons(self) -> None:
7779
self.assertEqual(result.returncode, 0, result.stderr)
7880
self.assertIn("Nombre:", result.stdout)
7981
self.assertIn("Fecha:", result.stdout)
82+
self.assertIn("Ultimo mensaje usuario:", result.stdout)
8083
self.assertIn("Descripcion:", result.stdout)
8184
self.assertNotIn("Path:", result.stdout)
8285
self.assertNotIn("Score:", result.stdout)
@@ -126,6 +129,7 @@ def test_missing_index_discovers_sessions_by_metadata(self) -> None:
126129
"payload": {
127130
"type": "message",
128131
"role": "user",
132+
"timestamp": "2026-04-24T09:15:00Z",
129133
"content": [{"type": "input_text", "text": "Migrate the assembly workflow."}],
130134
},
131135
},
@@ -137,6 +141,7 @@ def test_missing_index_discovers_sessions_by_metadata(self) -> None:
137141

138142
self.assertEqual(result.returncode, 0, result.stderr)
139143
payload = json.loads(result.stdout)
144+
self.assertEqual(payload[0]["last_user_message_at"], "2026-04-24T09:15:00Z")
140145
self.assertEqual(payload[0]["thread_name"], "Assembly migration")
141146
self.assertNotIn("session_path", payload[0])
142147

@@ -153,6 +158,7 @@ def test_indexed_session_path_can_match_arbitrary_id_in_filename(self) -> None:
153158
[
154159
{
155160
"type": "event_msg",
161+
"timestamp": "2026-04-25T12:05:00Z",
156162
"payload": {"type": "user_message", "message": "Find this arbitrary identifier session."},
157163
}
158164
],
@@ -165,6 +171,48 @@ def test_indexed_session_path_can_match_arbitrary_id_in_filename(self) -> None:
165171
self.assertEqual(len(payload), 1)
166172
self.assertEqual(payload[0]["thread_name"], "Indexed fallback")
167173

174+
def test_last_user_message_timestamp_uses_latest_user_message(self) -> None:
175+
with tempfile.TemporaryDirectory() as temp_dir:
176+
root = Path(temp_dir)
177+
write_jsonl(
178+
root / "sessions" / "2026" / "04" / "26" / "latest-user-message.jsonl",
179+
[
180+
{
181+
"type": "session_meta",
182+
"payload": {
183+
"id": "latest-user-message",
184+
"thread_name": "Latest user timestamp",
185+
"updated_at": "2026-04-26T09:00:00Z",
186+
},
187+
},
188+
{
189+
"type": "event_msg",
190+
"timestamp": "2026-04-26T09:05:00Z",
191+
"payload": {"type": "user_message", "message": "First timestamp check."},
192+
},
193+
{
194+
"type": "event_msg",
195+
"timestamp": "2026-04-26T09:10:00Z",
196+
"payload": {"type": "agent_message", "message": "Intermediate answer."},
197+
},
198+
{
199+
"type": "response_item",
200+
"timestamp": "2026-04-26T09:15:00Z",
201+
"payload": {
202+
"type": "message",
203+
"role": "user",
204+
"content": [{"type": "input_text", "text": "Second timestamp check."}],
205+
},
206+
},
207+
],
208+
)
209+
210+
result = self.run_cli(root, "--query", "timestamp", "--json")
211+
212+
self.assertEqual(result.returncode, 0, result.stderr)
213+
payload = json.loads(result.stdout)
214+
self.assertEqual(payload[0]["last_user_message_at"], "2026-04-26T09:15:00Z")
215+
168216
def test_archived_sessions_are_searched(self) -> None:
169217
with tempfile.TemporaryDirectory() as temp_dir:
170218
root = Path(temp_dir)

0 commit comments

Comments
 (0)