wellactually

Reference

Detector API v4

Everything @wellactually/sdk/v4 exports, for detector.ts and the files it imports. The types are the source of truth, and your editor shows them on hover.

ts
import { findUp, findWord, lineAt, matchesGlob, matchesGlobs, mentioned, onCommand, onDelete, onOutput, onPrompt, onRead, onWrite } from "@wellactually/sdk/v4";
import type { CommandEvent, DeleteEvent, File, Finding, OutputEvent, PromptEvent, ReadEvent, RemovedLine, TextFinding, WriteEvent, WrittenLine } from "@wellactually/sdk/v4";

Handlers#

Each registers a handler. Call them at the top level of detector.ts, as often as you need.

FunctionRunsTakes
onWrite(options)After the agent wrote or edited a file.{ globs?, detect(event: WriteEvent) }
onRead(options)After the agent read a file.{ globs?, detect(event: ReadEvent) }
onDelete(options)After the agent deleted a file.{ globs?, detect(event: DeleteEvent) }
onPrompt(options)When you sent a message, or the agent briefed a subagent.{ detect(event: PromptEvent) }
onCommand(options)Before a shell command runs.{ detect(event: CommandEvent) }
onOutput(options)After a shell command ran.{ detect(event: OutputEvent) }

detect returns findings: usually a generator, written *detect(event) { ... }, or any other iterable, or nothing. globs are patterns for event.file.relativePath: **, *, ?, {a,b}, and ! in front to take files out. Only onWrite, onRead and onDelete take globs.

Events#

Every event has these fields:

FieldTypeWhat it is
event"write", "read", "delete", "prompt", "command" or "output"Which event it is.
textstringThe file's content, the prompt, the command line, or what the command printed.
linesstring[]text split into lines. A finding's line counts in here.
projectstring or nullThe nearest folder upwards with a .wellactually.json or a .git, not the home folder. Often null, so do not rely on it.

And each kind adds its own:

EventAdds
WriteEventfile: File, written: WrittenLine[], removed: RemovedLine[]
ReadEventfile: File
DeleteEventfile: File as it was before it was deleted, removed: RemovedLine[] with every line of it
PromptEventcwd: string
CommandEventcwd: string, command: string (the same as text)
OutputEventcwd: string, command: string (the command that printed text)

cwd is the folder the session runs in, as an absolute path.

File#

FieldTypeWhat it is
pathstringAbsolute path, with forward slashes.
relativePathstringThe path from the project root. Globs match this.
namestringThe file name, config_loader.dart.
extstringThe extension with its dot, lower case, .dart. Empty when there is none.
contentstringThe whole file, after the write, or as it was before it was deleted.
linesstring[]content split into lines.
isNewbooleanThe write created the file. Always false on a read and a deletion.

WrittenLine#

FieldTypeWhat it is
linenumberThe 1-based line in the file.
textstringThe line as it is in the file, untrimmed, comments and strings included.

A handler of onWrite may also yield a WrittenLine as it is. It becomes a finding on its line.

RemovedLine#

FieldTypeWhat it is
linenumberThe 1-based line it had in the file before the change.
textstringThe line as it was, untrimmed.
removedtrueAlways true. It makes a removed line that is yielded as it is a finding on that line.

A line the agent changed is removed as it was and written as it is now. removed is empty when the host cannot tell what the file was before, as for a new file. A handler of onWrite or onDelete may yield a RemovedLine as it is, and the agent is shown "removed line 3: ..." with its text. Because a deletion has removed too, one helper serves both:

ts
function* removedTests(event: WriteEvent | DeleteEvent) {
  for (const removed of event.removed) {
    if (removed.text.includes("test(")) {
      yield removed;
    }
  }
}

onWrite({ globs: ["test/**"], detect: removedTests });
onDelete({ globs: ["test/**"], detect: removedTests });

Finding#

What onWrite, onRead, onDelete and onOutput yield. Nothing in it is required.

FieldTypeWhat it is
linenumberThe 1-based line of the event's text. Without it, a file handler's finding points at the file.
endLinenumberThe last line, when the finding spans several. Needs line.
matchstringText that occurs word for word in the event, on the named lines, and does not span lines. At most 300 characters.
filestringAnother file the finding is about, from the project root or absolute. It exists, or occurs word for word in the event. At most 300 characters.
depth"pointer" or "full""pointer" shows only the summary. Leave it out for "full".
removedbooleanline and endLine name removed lines, by the line each had before, and match occurs in them. Every line from line to endLine is in event.removed. Needs line.

TextFinding is what onPrompt and onCommand yield: the same without line and endLine. A detail that is wrong is left out and the finding still fires. Findings explains each field with examples.

Helpers#

findUp(name, from)#

ts
findUp(name: string, from: string): string | null

The nearest file called name, in from or a folder above it, as an absolute path, or null.

ts
findUp("pubspec.yaml", event.cwd); // for a prompt, a command or an output
findUp("pubspec.yaml", path.dirname(event.file.path)); // for a file

mentioned(event, names)#

ts
mentioned(event: { text: string }, names: readonly string[]): TextFinding[]

A pointer finding for the first of names that the event's text mentions, or an empty list. A name counts where it stands alone, in any case, and with or without the separators people put inside names: rxdart also finds rx_dart, rx-dart and RxDart. A space in a name stands for any whitespace. Pass it on with yield*.

ts
yield* mentioned(event, ["late keyword", "late final", "LateInitializationError"]);

findWord(text, word, from?)#

ts
findWord(text: string, word: string, from?: number): number

The index of word in text where it stands alone, or -1. findWord("isolate", "late") is -1, and findWord("late final x", "late") is 0.

lineAt(text, offset)#

ts
lineAt(text: string, offset: number): number

The 1-based line of a character offset, for a detector that searches the whole event.text with a regular expression.

ts
for (const match of event.text.matchAll(/Future\.delayed\(/g)) {
  yield { line: lineAt(event.text, match.index), match: "Future.delayed(" };
}

matchesGlob(path, pattern) and matchesGlobs(path, globs)#

ts
matchesGlob(path: string, pattern: string): boolean
matchesGlobs(path: string, globs: readonly string[]): boolean

The glob matching the handlers use, for a detector that decides about other paths itself, such as the files readdirSync lists. A pattern without a slash matches the file name in any folder.

The EVENTS constant#

["write", "read", "delete", "prompt", "command", "output"], the names of the events.

node:fs and node:path#

A detector may import node:fs and node:path. fs has readFileSync, existsSync, statSync and readdirSync, read-only and synchronous. See Reading project files.

Versions of the API#

VersionWhat it is
1One detect(ctx) function. No longer run.
2Absolute paths, relativePath, isNew, project, and reading files. No longer run.
3A handler per event, typed events, globs on the file handlers, findings without text of their own, and the output event. Still run.
4The lines a write removed, event.removed, and onDelete for a deleted file. Current.

A detector says which version it is written against by its import path, @wellactually/sdk/v4. A detector on version 3 keeps running as it was: it never sees removed lines or deletions. Detector API versions lists what each version added, and everything version 3 offers.