@taylorsatula/pi-timestamp 0.1.0 → 0.2.1

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.md CHANGED
@@ -4,31 +4,47 @@ Timestamp injection extension for [Pi](https://pi.dev) — the model always know
4
4
 
5
5
  ## What it does
6
6
 
7
- Hooks `before_provider_request` and appends a synthetic tool-call pair to the outgoing message list, carrying the current local wall-clock time:
8
-
9
- ```jsonc
10
- [
11
- {
12
- "role": "assistant",
13
- "tool_calls": [
14
- {
15
- "id": "ts_1756220400000",
16
- "type": "function",
17
- "function": { "name": "__timestamp", "arguments": "{\"timestamp\":\"[3:00pm - 8/26/25]\"}" }
18
- }
19
- ]
20
- },
21
- { "role": "tool", "tool_call_id": "ts_1756220400000", "content": "ok" }
22
- ]
23
- ```
24
-
25
- Injection happens only at **turn boundaries**, not on every provider call:
7
+ Hooks `before_provider_request` and appends the current local wall-clock time as plain text
8
+ to the tail of the last message:
26
9
 
27
- - Last message is a **user message** → inject.
28
- - Last message is an **assistant message with text content** (a vocalization between tool steps) → inject.
29
- - Last message is an assistant message with only `tool_calls`, or a tool result (mid-turn churn) → skip.
10
+ ```
11
+ They're injected poorly if they're that annoying.
12
+ [11:34pm - 9/1/26]
13
+ ```
30
14
 
31
- This keeps the timestamp fresh each time control returns to the user-facing side of the loop, without spamming intermediate tool-loop requests.
15
+ Injection happens only at **turn boundaries**, never mid-tool-loop, and handles both wire
16
+ formats the hook can deliver (the payload is the provider-serialized request body, which
17
+ differs per API):
18
+
19
+ - **Chat Completions / Anthropic** (conversation under `messages`): last message is a
20
+ **user message** → append the timestamp to its content. Last message is an
21
+ **assistant message with text content** (a vocalization between tool steps) → append
22
+ to its content. Last message is an assistant message carrying `tool_calls`, a tool
23
+ result, or an Anthropic user message containing a `tool_result` block (that API's
24
+ tool-result encoding) → skip.
25
+ - **OpenAI Responses API** (conversation under `input`, typed items): last item is a
26
+ user item → append to its content blocks. Last item is an assistant `message` item
27
+ (vocalization) → append to its `output_text` blocks. Last item is a `function_call` or
28
+ `function_call_output` item → skip.
29
+
30
+ ## Why plain-text tail append (design notes)
31
+
32
+ - **Cache safety.** The tail of the message list is the only cache-safe injection point:
33
+ mutating the system prompt would invalidate the KV-cache prefix on every request; a
34
+ tail mutation re-processes only the last message. The system prompt is never touched.
35
+ - **No synthetic tool calls.** Appending after an assistant message that carries a
36
+ `tool_call` would require fabricating a tool-call pair (a call must be answered by its
37
+ result) — so those positions are simply never injected. In the positions that are
38
+ injected, no disguise is needed, so none is used. The model never appears to call a
39
+ nonexistent tool, and transcripts never render phantom calls.
40
+ - **Idempotent.** A previously appended tag is stripped before the fresh one is written,
41
+ so re-fires and retries cannot stack timestamps.
42
+ - **Content-shape aware.** String content concatenates directly; block-array content
43
+ (Anthropic-shaped) appends into or after the last text block; unknown shapes are left
44
+ untouched rather than guessed at.
45
+ - **Wire-format aware.** Chat Completions/Anthropic payloads (`messages`) and OpenAI
46
+ Responses payloads (`input`, typed items) are both handled; any other payload shape
47
+ passes through untouched.
32
48
 
33
49
  ## Installation
34
50
 
@@ -46,9 +62,18 @@ Or add to `~/.pi/agent/settings.json` under `packages`:
46
62
 
47
63
  ## Notes
48
64
 
49
- - Timestamps use the machine's **local time**, formatted `h:mmam/pm - M/D/YY` (e.g. `[3:07pm - 8/26/25]`).
50
- - No tools, commands, shortcuts, or settings are registered — the extension is invisible except for the injected messages.
51
- - The injected pair is added to the outgoing payload only; it is not persisted to the session transcript.
65
+ - Timestamps use the machine's **local time**, formatted `h:mmam/pm - M/D/YY`
66
+ (e.g. `[3:07pm - 8/26/25]`).
67
+ - No tools, commands, shortcuts, or settings are registered — the extension is invisible
68
+ except for the appended tag.
69
+ - The tag is added to the outgoing payload only; it is not persisted to the session
70
+ transcript.
71
+ - Version 0.2.0 changed the mechanism (plain-text tail append replacing the synthetic
72
+ tool-call pair). Sessions started before the upgrade may still show one old-style pair
73
+ until restart.
74
+ - Version 0.2.1 fixed a crash on OpenAI Responses-API models (`before_provider_request`
75
+ payloads carry the conversation under `input`, not `messages`) and added
76
+ Anthropic `tool_result` skipping.
52
77
 
53
78
  ## Development
54
79
 
@@ -1,5 +1,31 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ // Cache-correct tail injection: mutating the system prompt would invalidate the
4
+ // KV-cache prefix on every request; the last message is the only cache-safe
5
+ // injection point, and only when no tool call is pending.
6
+ //
7
+ // Design:
8
+ // - Inject into user messages and text-only assistant vocalizations by
9
+ // appending plain text to the message content. No fabricated tool-call
10
+ // pair — the pair disguise is only structurally required in tool-call
11
+ // positions, which we never inject into.
12
+ // - Never inject after an assistant message carrying tool_calls (appending
13
+ // there would require wedging a pair between the call and its result) and
14
+ // never after tool results (mid-loop churn).
15
+ // - Idempotent per request: a previously appended tag is stripped before the
16
+ // fresh one is written.
17
+ //
18
+ // Payload shapes (before_provider_request delivers the provider-serialized
19
+ // request body, which differs per API):
20
+ // - Chat Completions / Anthropic: conversation lives under `messages`.
21
+ // - OpenAI Responses API: conversation lives under `input`, with typed items
22
+ // (input_text / output_text content blocks, function_call and
23
+ // function_call_output tool traffic) instead of chat messages.
24
+
25
+ type Rec = Record<string, unknown>;
26
+
27
+ const TS_STRIP = /\n\[\d{1,2}:\d{2}(am|pm) - \d{1,2}\/\d{1,2}\/\d{2}\]\s*$/;
28
+
3
29
  function formatTimestamp(): string {
4
30
  const now = new Date();
5
31
  let hours = now.getHours();
@@ -13,52 +39,79 @@ function formatTimestamp(): string {
13
39
  return `[${hours}:${String(minutes).padStart(2, "0")}${ampm} - ${month}/${day}/${year}]`;
14
40
  }
15
41
 
16
- function shouldInject(msgs: Array<Record<string, unknown>>): boolean {
17
- const last = msgs[msgs.length - 1];
18
- if (!last) return false;
42
+ // Chat Completions / Anthropic shape: payload.messages
43
+ // user: { role: "user", content: string | blocks }
44
+ // assistant vocalization: { role: "assistant", content: string | blocks, no tool_calls }
45
+ // assistant tool call: { role: "assistant", tool_calls: [...] }
46
+ // Anthropic tool results arrive as user messages containing a tool_result
47
+ // content block; treat them as mid-loop traffic, not turn boundaries.
19
48
 
20
- // User message → inject
21
- if (last.role === "user") return true;
22
-
23
- // Assistant with text content (vocalization) → inject
24
- // Assistant with only tool_calls (intermediate) → skip
49
+ function chatShouldInject(last: Rec): boolean {
50
+ if (last.role === "user") {
51
+ const content = last.content;
52
+ if (Array.isArray(content) && content.some((b) => (b as Rec).type === "tool_result")) {
53
+ return false;
54
+ }
55
+ return true;
56
+ }
25
57
  if (last.role === "assistant") {
26
58
  const hasToolCalls = Array.isArray(last.tool_calls) && last.tool_calls.length > 0;
27
59
  const hasContent = last.content != null && last.content !== "";
28
- return !hasToolCalls && hasContent;
60
+ return !hasToolCalls && hasContent; // vocalization → inject; tool call → never
29
61
  }
30
-
31
- // Tool results → skip (intermediate)
32
62
  return false;
33
63
  }
34
64
 
65
+ // OpenAI Responses shape: payload.input
66
+ // user: { role: "user", content: [{ type: "input_text", text }, ...] }
67
+ // vocalization: { type: "message", role: "assistant", content: [{ type: "output_text", ... }] }
68
+ // tool call: { type: "function_call", ... } / result: { type: "function_call_output", ... }
69
+
70
+ function responsesShouldInject(last: Rec): boolean {
71
+ if (last.role === "user") return true;
72
+ if (last.type === "message" && last.role === "assistant") {
73
+ return Array.isArray(last.content) && last.content.length > 0;
74
+ }
75
+ return false; // function_call / function_call_output → mid-loop, skip
76
+ }
77
+
35
78
  export default function (pi: ExtensionAPI) {
36
79
  pi.on("before_provider_request", (event) => {
37
- const payload = event.payload as Record<string, unknown>;
38
- const msgs = payload.messages as Array<Record<string, unknown>>;
80
+ const payload = event.payload as Rec;
81
+ const isResponses = Array.isArray(payload.input);
82
+ const items = isResponses
83
+ ? (payload.input as Rec[])
84
+ : Array.isArray(payload.messages)
85
+ ? (payload.messages as Rec[])
86
+ : undefined;
87
+ if (!items) return; // unknown payload shape → leave untouched
39
88
 
40
- if (!shouldInject(msgs)) return;
89
+ const last = items[items.length - 1];
90
+ if (!last) return;
91
+ if (!(isResponses ? responsesShouldInject(last) : chatShouldInject(last))) return;
41
92
 
42
- const timestamp = formatTimestamp();
43
- const callId = `ts_${Date.now()}`;
93
+ const ts = `\n${formatTimestamp()}`;
44
94
 
45
- msgs.push(
46
- {
47
- role: "assistant",
48
- tool_calls: [
49
- {
50
- id: callId,
51
- type: "function",
52
- function: { name: "__timestamp", arguments: JSON.stringify({ timestamp }) },
53
- },
54
- ],
55
- },
56
- {
57
- role: "tool",
58
- tool_call_id: callId,
59
- content: "ok",
60
- },
61
- );
95
+ if (typeof last.content === "string") {
96
+ last.content = last.content.replace(TS_STRIP, "") + ts;
97
+ } else if (Array.isArray(last.content)) {
98
+ // Provider-shaped block arrays: normalize the last text-bearing block in
99
+ // place, or append a typed text block when none exists.
100
+ const blocks = [...(last.content as Rec[])];
101
+ const idx = blocks.length - 1;
102
+ const lastBlock = blocks[idx] as { text?: unknown } | undefined;
103
+ if (lastBlock && typeof lastBlock.text === "string") {
104
+ blocks[idx] = { ...lastBlock, text: lastBlock.text.replace(TS_STRIP, "") + ts };
105
+ } else if (isResponses && last.role === "assistant") {
106
+ blocks.push({ type: "output_text", text: ts, annotations: [] });
107
+ } else if (isResponses) {
108
+ blocks.push({ type: "input_text", text: ts });
109
+ } else {
110
+ blocks.push({ type: "text", text: ts });
111
+ }
112
+ last.content = blocks;
113
+ }
114
+ // Non-string, non-array content shapes: leave untouched rather than guess.
62
115
 
63
116
  return event.payload;
64
117
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@taylorsatula/pi-timestamp",
3
- "version": "0.1.0",
4
- "description": "Timestamp injection extension for Pi — appends the current wall-clock time as a synthetic tool-call pair to every provider request at turn boundaries, so the model always knows what time it is.",
3
+ "version": "0.2.1",
4
+ "description": "Timestamp injection extension for Pi — appends the current wall-clock time to the tail of user messages and vocalizations just-in-time (cache-safe, no synthetic tool calls), so the model always knows what time it is.",
5
5
  "keywords": [
6
6
  "pi-package"
7
7
  ],