clankerbend 0.1.0

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.
@@ -0,0 +1,413 @@
1
+ export const STICKY_NOTES_APP_ID = "onewill.sticky-notes";
2
+
3
+ export function createStickyNotesApp(options = {}) {
4
+ const notes = new Map();
5
+
6
+ return {
7
+ appId: STICKY_NOTES_APP_ID,
8
+ name: "Sticky Notes",
9
+ publicDir: options.publicDir,
10
+ contributes: {
11
+ panel: true,
12
+ annotations: true,
13
+ actions: true,
14
+ appState: true,
15
+ selectionActions: true,
16
+ overlays: true,
17
+ composerContext: true,
18
+ composerDraft: true
19
+ },
20
+ permissions: {
21
+ transcriptRead: true,
22
+ transcriptAnnotate: true,
23
+ transcriptNavigate: true,
24
+ overlayWrite: true,
25
+ composerWrite: true,
26
+ appServerRead: false,
27
+ appServerApprove: false,
28
+ appServerRollback: false
29
+ },
30
+ panel: {
31
+ title: "Sticky Notes",
32
+ reloadPolicy: "preserve",
33
+ preferredWidth: 380
34
+ },
35
+
36
+ getManifest(context) {
37
+ return {
38
+ oneWhackVersion: context.protocolVersion,
39
+ appId: STICKY_NOTES_APP_ID,
40
+ name: "Sticky Notes",
41
+ entry: context.entry,
42
+ contributes: this.contributes,
43
+ permissions: this.permissions,
44
+ panel: this.panel
45
+ };
46
+ },
47
+
48
+ getState(context) {
49
+ const noteList = [...notes.values()];
50
+ return {
51
+ appId: STICKY_NOTES_APP_ID,
52
+ status: "ready",
53
+ source: context.desktop.target?.type === "mock" ? "mock" : "host-api",
54
+ connected: context.desktop.cdpStatus === "connected",
55
+ entries: noteList.map((note) => noteEntry(note)),
56
+ annotations: annotationsForNotes(noteList, context),
57
+ selectionActions: [
58
+ selectionAction("sticky.addToChat", "Add to chat", "plus-square"),
59
+ selectionAction("sticky.note.open", "Add note", "sticky-note")
60
+ ],
61
+ updatedAt: context.state.generatedAt
62
+ };
63
+ },
64
+
65
+ async handleAction(action, context) {
66
+ if (action.type === "sticky.note.open") {
67
+ const selection = selectionFromAction(action, context);
68
+ return openNoteOverlay(action, context, selection);
69
+ }
70
+
71
+ if (action.type === "sticky.note.create") {
72
+ const selection = selectionFromAction(action, context);
73
+ const body = String(action.payload?.body || action.payload?.note || "").trim();
74
+ if (!body) throw context.httpError(400, "bad_request", "note body is required");
75
+ const note = persistNoteFile(normalizeNote({
76
+ noteId: action.payload?.noteId,
77
+ body,
78
+ selection,
79
+ status: "queued",
80
+ requestedAt: action.requestedAt
81
+ }), context);
82
+ notes.set(note.noteId, note);
83
+ context.closeOverlay(action.payload?.overlayId, { broadcast: false });
84
+ attachNoteFile(notes, note.noteId, context);
85
+ return applied(action, { note, file: note.file, attachment: note.attachment });
86
+ }
87
+
88
+ if (action.type === "sticky.note.update") {
89
+ const noteId = requireNoteId(action, context);
90
+ const current = requireNote(notes, noteId, context);
91
+ const body = String(action.payload?.body || action.payload?.note || "").trim();
92
+ if (!body) throw context.httpError(400, "bad_request", "note body is required");
93
+ const note = persistNoteFile({ ...current, body, updatedAt: action.requestedAt || new Date().toISOString(), attachment: null }, context);
94
+ notes.set(noteId, note);
95
+ attachNoteFile(notes, note.noteId, context);
96
+ return applied(action, { note, file: note.file, attachment: note.attachment });
97
+ }
98
+
99
+ if (action.type === "sticky.note.delete") {
100
+ const noteId = requireNoteId(action, context);
101
+ const current = requireNote(notes, noteId, context);
102
+ notes.delete(noteId);
103
+ context.removeComposerContext(contextItemId(current.noteId), { broadcast: false });
104
+ return applied(action, { noteId, deleted: true });
105
+ }
106
+
107
+ if (action.type === "sticky.note.resolve") {
108
+ const noteId = requireNoteId(action, context);
109
+ const current = requireNote(notes, noteId, context);
110
+ const note = { ...current, status: "resolved", updatedAt: action.requestedAt || new Date().toISOString() };
111
+ notes.set(noteId, note);
112
+ context.removeComposerContext(contextItemId(noteId), { broadcast: false });
113
+ return applied(action, { note });
114
+ }
115
+
116
+ if (action.type === "sticky.addToChat") {
117
+ const selection = selectionFromAction(action, context);
118
+ const item = context.addComposerContext(selectionContextItem(action, selection), { broadcast: false }).item;
119
+ return applied(action, { contextItem: item });
120
+ }
121
+
122
+ if (action.type === "sticky.compose.insertContext") {
123
+ const text = composePrompt(context, action.payload?.userText || "");
124
+ const result = await context.setComposerDraft({
125
+ text,
126
+ mode: action.payload?.mode || "replace",
127
+ contextItemIds: context.state.composer.contextItems
128
+ .filter((item) => item.appId === STICKY_NOTES_APP_ID)
129
+ .map((item) => item.itemId)
130
+ }, { broadcast: false });
131
+ return applied(action, { draft: result.draft || context.state.composer.draft });
132
+ }
133
+
134
+ throw context.httpError(404, "not_found", `unknown action: ${action.type}`);
135
+ }
136
+ };
137
+ }
138
+
139
+ function selectionFromAction(action, context) {
140
+ const selection = action.payload?.selection || context.selection;
141
+ if (!selection?.anchorId) throw context.httpError(400, "bad_request", "selection.anchorId is required");
142
+ const range = selection.range || {
143
+ anchorId: selection.anchorId,
144
+ text: selection.quote || context.findAnchor(selection.anchorId)?.textPreview || "",
145
+ quote: selection.quote || context.findAnchor(selection.anchorId)?.textPreview || "",
146
+ prefix: "",
147
+ suffix: ""
148
+ };
149
+ return {
150
+ selectionId: selection.selectionId || `sel_${action.actionId}`,
151
+ anchorId: selection.anchorId,
152
+ quote: selection.quote || range.quote || range.text || "",
153
+ range: { ...range, anchorId: selection.anchorId },
154
+ rect: selection.rect || null,
155
+ selectedAt: selection.selectedAt || action.requestedAt || new Date().toISOString()
156
+ };
157
+ }
158
+
159
+ function openNoteOverlay(action, context, selection) {
160
+ const overlay = context.openOverlay({
161
+ appId: STICKY_NOTES_APP_ID,
162
+ kind: "form",
163
+ title: "Add note",
164
+ anchorId: selection.anchorId,
165
+ range: selection.range,
166
+ anchorRect: selection.rect,
167
+ fields: [{
168
+ fieldId: "body",
169
+ kind: "textarea",
170
+ label: "Add note",
171
+ value: action.payload?.body || ""
172
+ }],
173
+ actions: [{
174
+ label: "Save",
175
+ type: "sticky.note.create",
176
+ payload: { selection }
177
+ }]
178
+ }, { broadcast: false }).overlay;
179
+ return applied(action, { overlay });
180
+ }
181
+
182
+ function normalizeNote(input) {
183
+ const now = input.requestedAt || new Date().toISOString();
184
+ const quote = input.selection.quote || input.selection.range?.text || "";
185
+ return {
186
+ noteId: input.noteId || `note_${hashId(`${input.selection.anchorId}:${quote}:${input.body}:${now}`)}`,
187
+ appId: STICKY_NOTES_APP_ID,
188
+ anchorId: input.selection.anchorId,
189
+ quote,
190
+ range: input.selection.range,
191
+ rect: input.selection.rect || null,
192
+ body: input.body,
193
+ file: input.file || null,
194
+ attachment: input.attachment || null,
195
+ status: input.status || "queued",
196
+ createdAt: now,
197
+ updatedAt: now
198
+ };
199
+ }
200
+
201
+ function persistNoteFile(note, context) {
202
+ if (!context.writeRuntimeFile) return note;
203
+ const file = context.writeRuntimeFile({
204
+ fileId: `sticky-note:${note.noteId}`,
205
+ directory: "notes",
206
+ filename: noteFilename(note),
207
+ mimeType: "text/markdown; charset=utf-8",
208
+ content: noteMarkdown(note)
209
+ });
210
+ return { ...note, file, updatedAt: note.updatedAt || file.createdAt };
211
+ }
212
+
213
+ function attachNoteFile(notes, noteId, context) {
214
+ const note = notes.get(noteId);
215
+ if (!note?.file || !context.attachRuntimeFiles) return;
216
+ const pending = { ok: null, status: "pending", requestedAt: new Date().toISOString() };
217
+ notes.set(noteId, { ...note, attachment: pending });
218
+ context.attachRuntimeFiles([note.file], { broadcast: false })
219
+ .then((attachment) => {
220
+ const current = notes.get(noteId);
221
+ if (!current) return;
222
+ notes.set(noteId, { ...current, attachment, updatedAt: new Date().toISOString() });
223
+ context.requestStateBroadcast?.();
224
+ })
225
+ .catch((err) => {
226
+ const current = notes.get(noteId);
227
+ if (!current) return;
228
+ notes.set(noteId, { ...current, attachment: { ok: false, error: err.message }, updatedAt: new Date().toISOString() });
229
+ context.requestStateBroadcast?.();
230
+ });
231
+ }
232
+
233
+ function noteMarkdown(note) {
234
+ return [
235
+ "# Sticky Note",
236
+ "",
237
+ `Note ID: ${note.noteId}`,
238
+ `Status: ${note.status}`,
239
+ `Anchor: ${note.anchorId}`,
240
+ `Created: ${note.createdAt}`,
241
+ `Updated: ${note.updatedAt}`,
242
+ "",
243
+ "## Note",
244
+ "",
245
+ note.body,
246
+ "",
247
+ "## Highlighted Text",
248
+ "",
249
+ blockquote(note.quote),
250
+ "",
251
+ "## Range",
252
+ "",
253
+ "```json",
254
+ JSON.stringify(note.range || null, null, 2),
255
+ "```",
256
+ ""
257
+ ].join("\n");
258
+ }
259
+
260
+ function blockquote(text) {
261
+ return String(text || "")
262
+ .split(/\r?\n/)
263
+ .map((line) => `> ${line}`)
264
+ .join("\n");
265
+ }
266
+
267
+ function noteContextItem(note) {
268
+ return {
269
+ itemId: contextItemId(note.noteId),
270
+ appId: STICKY_NOTES_APP_ID,
271
+ label: labelFromQuote(note.quote),
272
+ body: note.body,
273
+ anchorId: note.anchorId,
274
+ range: note.range,
275
+ status: note.status === "resolved" ? "resolved" : "queued"
276
+ };
277
+ }
278
+
279
+ function selectionContextItem(action, selection) {
280
+ return {
281
+ itemId: `selection_${hashId(`${selection.anchorId}:${selection.quote}:${action.actionId}`)}`,
282
+ appId: STICKY_NOTES_APP_ID,
283
+ label: labelFromQuote(selection.quote || selection.range?.text || selection.anchorId),
284
+ body: selection.quote || selection.range?.text || "",
285
+ anchorId: selection.anchorId,
286
+ range: selection.range,
287
+ status: "queued"
288
+ };
289
+ }
290
+
291
+ function composePrompt(context, userText) {
292
+ const items = context.state.composer.contextItems.filter((item) => item.appId === STICKY_NOTES_APP_ID);
293
+ const sections = items.map((item, index) => {
294
+ const quote = item.range?.quote || item.range?.text || item.label;
295
+ return `Context ${index + 1}: ${item.label}\nSelected text: ${quote}\nNote: ${item.body || quote}`;
296
+ });
297
+ return [
298
+ "Use these selected transcript notes as context:",
299
+ "",
300
+ ...sections.flatMap((section) => [section, ""]),
301
+ "User request:",
302
+ String(userText || "").trim()
303
+ ].join("\n").trim();
304
+ }
305
+
306
+ function annotationsForNotes(notes, context) {
307
+ const selectedAnchorId = context.selection?.anchorId;
308
+ return notes
309
+ .filter((note) => note.status !== "resolved")
310
+ .map((note) => ({
311
+ appId: STICKY_NOTES_APP_ID,
312
+ anchorId: note.anchorId,
313
+ placement: "leading-rail",
314
+ priority: 70,
315
+ markers: [{
316
+ markerId: `sticky:${note.noteId}`,
317
+ glyph: "N",
318
+ label: note.body,
319
+ tone: note.anchorId === selectedAnchorId ? "accent" : "warning",
320
+ shape: "pill",
321
+ entryId: `sticky:${note.noteId}`,
322
+ action: {
323
+ type: "sticky.note.open",
324
+ payload: { noteId: note.noteId, selection: { anchorId: note.anchorId, quote: note.quote, range: note.range } }
325
+ }
326
+ }]
327
+ }));
328
+ }
329
+
330
+ function noteEntry(note) {
331
+ return {
332
+ entryId: `sticky:${note.noteId}`,
333
+ appId: STICKY_NOTES_APP_ID,
334
+ anchorId: note.anchorId,
335
+ title: labelFromQuote(note.quote),
336
+ summary: note.body,
337
+ category: "note",
338
+ tone: note.status === "resolved" ? "muted" : "warning",
339
+ status: note.status,
340
+ detail: {
341
+ quote: note.quote,
342
+ file: note.file,
343
+ attachment: note.attachment
344
+ },
345
+ occurredAt: note.updatedAt
346
+ };
347
+ }
348
+
349
+ function selectionAction(type, label, icon) {
350
+ return {
351
+ actionId: type,
352
+ appId: STICKY_NOTES_APP_ID,
353
+ type,
354
+ label,
355
+ icon,
356
+ appliesTo: "text-selection",
357
+ enabled: true
358
+ };
359
+ }
360
+
361
+ function applied(action, data) {
362
+ return {
363
+ actionId: action.actionId,
364
+ appId: STICKY_NOTES_APP_ID,
365
+ ok: true,
366
+ status: "applied",
367
+ data,
368
+ completedAt: new Date().toISOString()
369
+ };
370
+ }
371
+
372
+ function requireNoteId(action, context) {
373
+ const noteId = action.payload?.noteId;
374
+ if (!noteId) throw context.httpError(400, "bad_request", "noteId is required");
375
+ return noteId;
376
+ }
377
+
378
+ function requireNote(notes, noteId, context) {
379
+ const note = notes.get(noteId);
380
+ if (!note) throw context.httpError(404, "not_found", "note not found");
381
+ return note;
382
+ }
383
+
384
+ function contextItemId(noteId) {
385
+ return `sticky:${noteId}`;
386
+ }
387
+
388
+ function labelFromQuote(quote) {
389
+ const text = String(quote || "selected text").replace(/\s+/g, " ").trim();
390
+ return text.length > 28 ? `${text.slice(0, 25)}...` : text;
391
+ }
392
+
393
+ function noteFilename(note) {
394
+ const words = String(note.body || "")
395
+ .toLowerCase()
396
+ .replace(/['"]/g, "")
397
+ .replace(/[^a-z0-9]+/g, " ")
398
+ .trim()
399
+ .split(/\s+/)
400
+ .filter(Boolean)
401
+ .slice(0, 6);
402
+ const slug = words.join("-") || "untitled";
403
+ const suffix = hashId(note.noteId).slice(0, 5);
404
+ return `note_${slug}_${suffix}.md`;
405
+ }
406
+
407
+ function hashId(value) {
408
+ let hash = 0;
409
+ for (let index = 0; index < value.length; index += 1) {
410
+ hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
411
+ }
412
+ return Math.abs(hash).toString(36);
413
+ }
@@ -0,0 +1,126 @@
1
+ # OneWhack Vim Navigator
2
+
3
+ OneWhack is an independent OneWill project compatible with OpenAI Codex
4
+ Desktop. It is not affiliated with or endorsed by OpenAI.
5
+
6
+ Vim Navigator is the public reference app for OneWhack `0.1`. It demonstrates
7
+ how an app registers with a central OneWhack host and uses transcript anchors,
8
+ annotations, selection, and app-scoped actions without owning CDP directly.
9
+
10
+ ## Run
11
+
12
+ ```sh
13
+ cd onewhack
14
+ npm start
15
+ ```
16
+
17
+ The command starts a loopback OneWhack host on an ephemeral `127.0.0.1` port,
18
+ launches Codex Desktop with CDP, injects transcript markers, and serves the Vim
19
+ Navigator panel at `/apps/onewill.vim-nav/`.
20
+
21
+ For protocol-only mock mode:
22
+
23
+ ```sh
24
+ npm run start:mock
25
+ ```
26
+
27
+ ## Test
28
+
29
+ ```sh
30
+ npm test
31
+ ```
32
+
33
+ The test suite runs without Codex Desktop. It verifies the host manifest, public
34
+ state, app registry, app manifest/state, initial SSE state event, action
35
+ execution, duplicate action idempotency, action result lookup, stale selection
36
+ rejection, scroll/highlight behavior, request validation, unknown app/anchor
37
+ errors, HTTP status/envelope alignment, and bearer-token behavior.
38
+
39
+ ## Commands
40
+
41
+ Codex Desktop routes normal typing into the prompt composer, so enter transcript
42
+ navigation mode first:
43
+
44
+ - `Ctrl+Alt`: toggle Vim transcript mode
45
+ - `Escape` or `i`: exit Vim transcript mode
46
+
47
+ While Vim transcript mode is active, plain Vim keys are captured for transcript
48
+ navigation:
49
+
50
+ - `j` / `k`: next or previous transcript anchor, aligned so the transcript number is visible at the top
51
+ - `gg` / `G`: transcript item `1` or the last known transcript number
52
+ - `42G`: jump to transcript item `42`; oversized numbers clamp to the last known transcript number
53
+ - `[` / `]`: align the current transcript item to the top or bottom
54
+ - `{` / `}`: previous or next user message
55
+
56
+ The injected mode badge appears in the bottom-right corner while the mode is
57
+ active.
58
+
59
+ The focus-safe chords remain available as a fallback:
60
+
61
+ - `Ctrl+Alt+j` or `Ctrl+Alt+Down`: next transcript anchor
62
+ - `Ctrl+Alt+k` or `Ctrl+Alt+Up`: previous transcript anchor
63
+ - `Ctrl+Alt+g` or `Ctrl+Alt+Home`: first transcript anchor
64
+ - `Ctrl+Alt+G` or `Ctrl+Alt+End`: last transcript anchor
65
+ - `Ctrl+Alt+{` or `Ctrl+Alt+PageUp`: previous user message
66
+ - `Ctrl+Alt+}` or `Ctrl+Alt+PageDown`: next user message
67
+
68
+ Panel commands support:
69
+
70
+ - `/term`: search visible transcript text
71
+ - `n` / `N`: next or previous search match
72
+ - `:42` or `42`: jump by visible ruler number
73
+ - `:latest assistant` / `:latest user`: jump by role
74
+
75
+ These commands are implemented as OneWhack app actions, not only as panel-local
76
+ keyboard parsing. External clients can call the same actions through
77
+ `POST /onewhack/apps/onewill.vim-nav/actions`.
78
+
79
+ ## Writing Another App
80
+
81
+ Create an object with:
82
+
83
+ - `appId`
84
+ - `name`
85
+ - `publicDir`
86
+ - `getManifest(context)`
87
+ - `getState(context)`
88
+ - `handleAction(action, context)`
89
+
90
+ Then register it with one host:
91
+
92
+ ```js
93
+ import { OneWhackHost, createMockTranscriptAdapter } from "../../host/src/index.js";
94
+
95
+ const host = new OneWhackHost({
96
+ transcriptAdapter: createMockTranscriptAdapter({ defaultAppId: "example.app" })
97
+ });
98
+
99
+ host.registerApp(exampleApp);
100
+ await host.start();
101
+ ```
102
+
103
+ The host serves all mounted apps through one central server and routes commands
104
+ through `/onewhack/apps/:appId/actions`.
105
+
106
+ ## Real Desktop Validation
107
+
108
+ The default `npm test` path is mock/protocol-only and does not launch Codex
109
+ Desktop. To run the real Desktop regression harness:
110
+
111
+ ```sh
112
+ cd onewhack
113
+ npm run test:vim-nav:desktop-real
114
+ ```
115
+
116
+ The harness starts the Vim Navigator host, launches Codex Desktop with CDP,
117
+ creates a fresh chat, seeds deterministic transcript turns, injects the host
118
+ renderer bridge, and verifies:
119
+
120
+ - `Ctrl+Alt` toggles Vim transcript mode without moving scroll
121
+ - `G` lands on the last known transcript item
122
+ - repeated `k` walks to transcript item `1` without skipping or renumbering
123
+ - `12G`, `gg`, and oversized `number+G` land on the expected anchors
124
+
125
+ It writes diagnostics to `apps/vim-nav/run/desktop-real-validation/result.json`
126
+ and cleans up the launched Codex/Desktop process before exiting.
@@ -0,0 +1,69 @@
1
+ {
2
+ "oneWhackVersion": "0.1",
3
+ "appId": "onewill.vim-nav",
4
+ "version": "0.1.0",
5
+ "name": "OneWill VimNav",
6
+ "description": "Public reference app for keyboard transcript navigation in Codex Desktop.",
7
+ "distribution": {
8
+ "kind": "local",
9
+ "source": ".",
10
+ "integrity": "dev-local",
11
+ "update": {
12
+ "channel": "local"
13
+ }
14
+ },
15
+ "entrypoint": {
16
+ "kind": "module",
17
+ "module": "./src/vim-nav-app.js",
18
+ "factory": "createVimNavigatorApp",
19
+ "publicDir": "./public"
20
+ },
21
+ "platform": {
22
+ "os": [
23
+ "darwin",
24
+ "linux",
25
+ "win32"
26
+ ],
27
+ "arch": [
28
+ "any"
29
+ ]
30
+ },
31
+ "capabilities": {
32
+ "panel": true,
33
+ "annotations": true,
34
+ "commands": true,
35
+ "actions": true,
36
+ "appState": true,
37
+ "rendererBridge": false
38
+ },
39
+ "permissions": {
40
+ "transcriptRead": true,
41
+ "transcriptAnnotate": true,
42
+ "transcriptNavigate": true,
43
+ "appServerRead": false,
44
+ "appServerApprove": false,
45
+ "appServerRollback": false
46
+ },
47
+ "panel": {
48
+ "title": "Vim Nav",
49
+ "reloadPolicy": "preserve",
50
+ "preferredWidth": 360
51
+ },
52
+ "lifecycle": {
53
+ "install": {
54
+ "kind": "noop"
55
+ },
56
+ "start": {
57
+ "kind": "in-process"
58
+ },
59
+ "stop": {
60
+ "kind": "in-process"
61
+ },
62
+ "update": {
63
+ "kind": "replace-manifest"
64
+ },
65
+ "remove": {
66
+ "kind": "forget"
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "onewhack-vim-nav",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Public OneWhack reference app for Vim-like Codex Desktop transcript navigation.",
6
+ "license": "MIT",
7
+ "scripts": {
8
+ "start": "node server.mjs",
9
+ "start:mock": "node server.mjs --mock",
10
+ "test": "node test-onewhack.mjs",
11
+ "test:desktop-real": "node test-desktop-real.mjs"
12
+ },
13
+ "engines": {
14
+ "node": ">=22"
15
+ }
16
+ }