@jancellor/ask 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 jancellor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # Ask
2
+
3
+ A minimal coding agent.
4
+
5
+ ![Ask demo](https://raw.githubusercontent.com/jancellor/ask/main/demo/demo.gif)
6
+
7
+ ## Why
8
+
9
+ Ask is a small, working coding agent built as a demo project.
10
+ It keeps the setup deliberately minimal: one shell execution tool and
11
+ standard CLI utilities (`rg`, `sed`, `cat`, etc.) instead of a large custom tool surface.
12
+ It supports AGENTS.md and skills.
13
+ The goal is to show the core loop clearly and keep the code easy to read, run, and modify.
14
+ It's a proof-of-concept.
15
+
16
+ Should you use this? Probably not for day-to-day work.
17
+ It runs with full shell access, so it needs an external sandbox.
18
+ Basic features are missing.
19
+ Even so, the core loop works and is useful for real coding tasks.
20
+ It should be easy to experiment with different task delegation patterns.
21
+ For example, subagents are just self invocations `ask "msg"`.
22
+ Usage is controlled by the system prompt.
23
+
24
+ ## Features
25
+
26
+ - **Single tool.** One `execute` tool runs bash commands. No specialized file-editing, search, or filesystem tools.
27
+ - **Interactive and scriptable.** Terminal UI with markdown rendering, or `ask "msg"` for batch mode use.
28
+ - **Session persistence.** Conversations are saved as JSONL to `~/.ask/sessions/`.
29
+ - **Subagent delegation.** An agent can simply invoke `ask "msg"` to isolate context or run in parallel.
30
+ - **Project-level instructions.** `AGENTS.md` files provide project-specific context. `SKILL.md` files describe reusable capabilities.
31
+
32
+ ## Setup
33
+
34
+ Install from npm:
35
+
36
+ ```bash
37
+ npm install -g @jancellor/ask
38
+ ```
39
+
40
+ Or build from source:
41
+
42
+ ```bash
43
+ git clone https://github.com/jancellor/ask.git
44
+ cd ask
45
+ npm install
46
+ npm run build
47
+ npm link
48
+ ```
49
+
50
+ Configure a provider:
51
+
52
+ ```bash
53
+ export ASK_API_KEY="your-api-key"
54
+ export ASK_MODEL="anthropic/claude-sonnet-4.6"
55
+ export ASK_BASE_URL="https://openrouter.ai/api/v1"
56
+ ```
57
+
58
+ Or create `~/.config/ask/config.json`:
59
+
60
+ ```json
61
+ {
62
+ "api_key": "your-api-key",
63
+ "model": "anthropic/claude-sonnet-4.6",
64
+ "base_url": "https://openrouter.ai/api/v1"
65
+ }
66
+ ```
67
+
68
+ Run:
69
+
70
+ ```bash
71
+ ask # Interactive mode
72
+ ask "refactor" # Batch mode (single positional arg)
73
+ cat file.ts | ask "explain" # Pipe context, ask a question
74
+ ask --resume # Resume most recent session (interactive)
75
+ ask --resume <uuid> # Resume a specific session
76
+ ask --resume -- "refactor" # Resume most recent session in batch mode
77
+ ask --fork # Fork most recent session into a new session (interactive)
78
+ ask --fork -- "try this" # Fork most recent session in batch mode
79
+ ask --resume <uuid> --fork # Fork a specific session into a new session
80
+ ask --help # More options
81
+ ```
82
+
83
+ ## Architecture
84
+
85
+ ```
86
+ User input
87
+ → generateText() via Vercel AI SDK
88
+ → Model returns text + tool calls
89
+ → execute({ command }) — bash -c with timeout
90
+ → stdout/stderr/exit code returned to model
91
+ → Loop until no more tool calls
92
+ → Append messages to session JSONL
93
+ ```
@@ -0,0 +1,107 @@
1
+ # System prompt
2
+
3
+ You are an expert coding agent or coding assistant.
4
+ You are typically invoked via the `ask` executable harness.
5
+ The user may refer to you as "Ask".
6
+ You help users with tasks including coding by executing commands
7
+ including those for searching, reading, editing and writing files.
8
+
9
+ Available tools:
10
+
11
+ - `execute`: execute shell commands using bash.
12
+
13
+ Guidelines:
14
+
15
+ - Read relevant files and understand context before making changes.
16
+ - Use `execute` for file operations like `ls`, `rg`, `fd`.
17
+ - When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did.
18
+ - Be concise in your responses.
19
+ - Show file paths clearly when working with files.
20
+
21
+ ## Searching
22
+
23
+ Use `ls`, `rg`, `fd` for exploring the filesystem.
24
+ Use flags for following symlinks, including hidden items,
25
+ and not ignoring ignored items where appropriate,
26
+ eg `ls -a`, `rg -L -uu`, `fd -L -u`.
27
+
28
+ ## File editing
29
+
30
+ ### Reading
31
+
32
+ Prefer `sed` to `cat` to avoid dumping large files into context.
33
+
34
+ ```bash
35
+ sed -n '1,200p' path/to/file
36
+ ```
37
+
38
+ ### Writing
39
+
40
+ Use `cat` with heredocs to create new files.
41
+ Check the file doesn't already exist before writing.
42
+
43
+ ```bash
44
+ cat > path/to/new-file <<'EOF'
45
+ <new content>
46
+ EOF
47
+ ```
48
+
49
+ ### Editing
50
+
51
+ Use `sd -F` with `cat` and heredocs for targeted edits.
52
+ Generally prefer making targeted edits rather than rewriting the entire file.
53
+ After editing, read back the modified region to verify.
54
+
55
+ ```bash
56
+ OLD_BLOCK=$(cat <<'OLD_EOF'
57
+ <old content>
58
+ OLD_EOF
59
+ )
60
+
61
+ NEW_BLOCK=$(cat <<'NEW_EOF'
62
+ <new content>
63
+ NEW_EOF
64
+ )
65
+
66
+ sd -F -- "$OLD_BLOCK" "$NEW_BLOCK" path/to/file
67
+ ```
68
+
69
+ ## Background processes
70
+
71
+ Use `tmux` when processes need to run in the background.
72
+ For full output, use `capture-pane -S -` or `pipe-pane` to file.
73
+ Prefix session names with "agents-".
74
+
75
+ ## Subagents, task delegation, and context management
76
+
77
+ Typically the user has invoked you by running `ask` from a terminal shell.
78
+ You may also run `ask "<msg>"` to invoke another copy of the agent,
79
+ which may be referred to as a subagent.
80
+ You may do this in shell commands or in scripts you execute to help you achieve your tasks.
81
+ However you should not use `ask` in code you generate for the user to run independently.
82
+ The point of delegating is to control the context which you, the main agent, and the subagent sees.
83
+ If you need to answer a complicated query in the middle of a conversation, by delegating to a subagent,
84
+ the subagent does not see the unnecessary full context of your conversation.
85
+ It only sees what you explicitly include in the prompt.
86
+ Similarly, you do not see the output of the intermediate steps the subagent used to answer the question.
87
+ By keeping the context of yourself and subagents limited to only the scope they require,
88
+ overall accuracy is typically improved.
89
+ Also, by invoking multiple subagents in a single turn, you can achieve parallelism
90
+ and therefore faster performance for tasks that are truly independent.
91
+
92
+ ## Session storage
93
+
94
+ When `ask` runs, messages are persisted to `~/.ask/sessions/<id>.jsonl` as AI SDK messages.
95
+ IDs are UUIDs.
96
+ Messages include additional metadata in a `_meta` property, including `id`, `parentId`, `uiHidden`, and `timestamp`.
97
+ When using subagents, consider passing an explicit session ID via `ask --resume <id> ...`.
98
+ This allows you to inspect the context of the subagent, though note that
99
+ usually you explicitly don't want the subagent context in your own.
100
+ This also allows you ask a subagent follow up question by supplying the same session ID twice.
101
+ It may be useful to make focused searches of the subagent context.
102
+
103
+ ## Web
104
+
105
+ For searching or fetching from the web, you may delegate to `codex`, another coding agent.
106
+
107
+ codex exec --skip-git-repo-check "What is the weather like in London today?"
@@ -0,0 +1,132 @@
1
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
2
+ import { generateText, } from 'ai';
3
+ import { ConfigReader } from './config.js';
4
+ import { InitPrompt } from './init-prompt.js';
5
+ import { Session } from './session.js';
6
+ import { SystemPrompt } from './system-prompt.js';
7
+ import { Serializer } from './serializer.js';
8
+ import { Tools } from './tools.js';
9
+ export const ABORTED_MESSAGE = '[Aborted]';
10
+ export const ERROR_MESSAGE = '[Error]';
11
+ export class Agent {
12
+ modelId;
13
+ baseUrl;
14
+ listeners = [];
15
+ languageModel;
16
+ systemPrompt;
17
+ tools;
18
+ session;
19
+ serializer = new Serializer();
20
+ controller = null;
21
+ constructor(session) {
22
+ this.session = session;
23
+ const config = new ConfigReader().read();
24
+ this.modelId = config.model;
25
+ this.baseUrl = config.baseUrl;
26
+ const provider = createOpenAICompatible({
27
+ name: 'ask',
28
+ apiKey: config.apiKey,
29
+ baseURL: config.baseUrl,
30
+ });
31
+ this.languageModel = provider(config.model);
32
+ this.systemPrompt = new SystemPrompt().build();
33
+ this.tools = new Tools();
34
+ }
35
+ static async create(options) {
36
+ const session = await Session.create(options);
37
+ return new Agent(session);
38
+ }
39
+ addListener(listener) {
40
+ this.listeners.push(listener);
41
+ }
42
+ removeListener(listener) {
43
+ const i = this.listeners.indexOf(listener);
44
+ if (i !== -1)
45
+ this.listeners.splice(i, 1);
46
+ }
47
+ get messages() {
48
+ return this.session.messages;
49
+ }
50
+ get sessionId() {
51
+ return this.session.sessionId;
52
+ }
53
+ ask(message) {
54
+ return this.serializer.submit(async () => {
55
+ await this.addInitialMessages();
56
+ await this.addMessages([{ role: 'user', content: message }]);
57
+ this.controller = new AbortController();
58
+ const { signal } = this.controller;
59
+ try {
60
+ while (true) {
61
+ const result = await generateText({
62
+ model: this.languageModel,
63
+ system: this.systemPrompt,
64
+ messages: this.session.messages,
65
+ tools: this.tools.definitions(),
66
+ abortSignal: signal,
67
+ });
68
+ await this.addMessages(result.response.messages);
69
+ if (result.toolCalls.length === 0)
70
+ break;
71
+ const toolResults = await this.callTools(result.toolCalls, signal);
72
+ await this.addMessages([{ role: 'tool', content: toolResults }]);
73
+ }
74
+ }
75
+ catch (e) {
76
+ const msg = e instanceof Error ? e.message : String(e);
77
+ await this.addMessages([
78
+ { role: 'assistant', content: ERROR_MESSAGE + ': ' + msg },
79
+ ]);
80
+ }
81
+ finally {
82
+ this.controller = null;
83
+ }
84
+ });
85
+ }
86
+ abort() {
87
+ this.controller?.abort();
88
+ }
89
+ async cancelAll() {
90
+ this.abort();
91
+ await this.serializer.cancelPending();
92
+ }
93
+ async clear(beforeClear) {
94
+ await this.cancelAll();
95
+ await this.serializer.submit(async () => {
96
+ beforeClear?.();
97
+ this.session = await this.session.cleared();
98
+ await Promise.all(this.listeners.map((l) => l.onClear?.()));
99
+ });
100
+ }
101
+ async fork(sessionId, beforeFork) {
102
+ await this.cancelAll();
103
+ await this.serializer.submit(async () => {
104
+ beforeFork?.();
105
+ await this.session.fork(sessionId);
106
+ await Promise.all(this.listeners.map((l) => l.onFork?.()));
107
+ });
108
+ }
109
+ async addMessages(newMessages, uiHidden = false) {
110
+ const appended = await this.session.append(newMessages, uiHidden);
111
+ await Promise.all(this.listeners.map((l) => l.onMessages?.(appended)));
112
+ }
113
+ async addInitialMessages() {
114
+ if (!this.session.messages.length) {
115
+ const initContent = await new InitPrompt().build();
116
+ if (initContent) {
117
+ await this.addMessages([{ role: 'user', content: initContent }], true);
118
+ }
119
+ }
120
+ }
121
+ async callTools(toolCalls, signal) {
122
+ return await Promise.all(toolCalls.map(async (toolCall) => ({
123
+ type: 'tool-result',
124
+ toolCallId: toolCall.toolCallId,
125
+ toolName: toolCall.toolName,
126
+ output: {
127
+ type: 'json',
128
+ value: await this.tools.execute(toolCall.toolName, toolCall.input, signal),
129
+ },
130
+ })));
131
+ }
132
+ }
@@ -0,0 +1,23 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ import { ancestorPaths } from './paths.js';
5
+ export class AgentsPrompt {
6
+ async build() {
7
+ const contents = (await Promise.all([join(homedir(), '.agents'), ...ancestorPaths(process.cwd())].map((dir) => this.tryRead(join(dir, 'AGENTS.md'))))).filter(Boolean);
8
+ if (!contents.length)
9
+ return '';
10
+ return [
11
+ 'Follow the instructions below that have come from AGENTS.md files.',
12
+ '<AGENT_INSTRUCTIONS>',
13
+ ...contents,
14
+ '</AGENT_INSTRUCTIONS>',
15
+ ].join('\n\n');
16
+ }
17
+ async tryRead(path) {
18
+ try {
19
+ return await readFile(path, 'utf-8');
20
+ }
21
+ catch { }
22
+ }
23
+ }
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ export class ConfigReader {
5
+ static CONFIG_PATH = join(homedir(), '.config', 'ask', 'config.json');
6
+ readConfigFile() {
7
+ try {
8
+ return JSON.parse(readFileSync(ConfigReader.CONFIG_PATH, 'utf-8'));
9
+ }
10
+ catch {
11
+ return {};
12
+ }
13
+ }
14
+ envOrFile(envKey, fileKey, fileValues) {
15
+ const envValue = process.env[envKey];
16
+ if (envValue)
17
+ return envValue.trim();
18
+ const fileValue = fileValues[fileKey];
19
+ if (fileValue != null)
20
+ return String(fileValue).trim();
21
+ throw new Error(`neither ${envKey} nor ${fileKey} is set`);
22
+ }
23
+ optionalEnvOrFile(envKey, fileKey, fileValues) {
24
+ const envValue = process.env[envKey];
25
+ if (envValue && envValue.trim())
26
+ return envValue.trim();
27
+ const fileValue = fileValues[fileKey];
28
+ if (fileValue == null)
29
+ return undefined;
30
+ const value = String(fileValue).trim();
31
+ return value ? value : undefined;
32
+ }
33
+ isOpenAIBaseUrl(baseUrl) {
34
+ try {
35
+ return new URL(baseUrl).hostname.includes('openai.com');
36
+ }
37
+ catch {
38
+ return baseUrl.includes('openai.com');
39
+ }
40
+ }
41
+ read() {
42
+ const fileValues = this.readConfigFile();
43
+ const baseUrl = this.envOrFile('ASK_BASE_URL', 'base_url', fileValues);
44
+ const apiKey = this.optionalEnvOrFile('ASK_API_KEY', 'api_key', fileValues);
45
+ if (!apiKey && !this.isOpenAIBaseUrl(baseUrl)) {
46
+ throw new Error('neither ASK_API_KEY nor api_key is set (required unless ASK_BASE_URL/base_url points to openai.com)');
47
+ }
48
+ return {
49
+ apiKey: apiKey ?? 'oauth',
50
+ model: this.envOrFile('ASK_MODEL', 'model', fileValues),
51
+ baseUrl,
52
+ };
53
+ }
54
+ }
@@ -0,0 +1,80 @@
1
+ import { tool } from 'ai';
2
+ import { spawn } from 'child_process';
3
+ import process from 'process';
4
+ import { z } from 'zod';
5
+ const DEFAULT_TIMEOUT_S = 60;
6
+ const TERMINATION_GRACE_MS = 5000;
7
+ const executeInputSchema = z.object({
8
+ command: z
9
+ .string()
10
+ .describe('The shell command to execute using `bash -c`'),
11
+ });
12
+ export class ExecuteTool {
13
+ name = 'execute';
14
+ definition() {
15
+ return tool({
16
+ description: 'Executes a shell command using `bash -c`. ' +
17
+ 'Can be multiline or whatever `bash -c` accepts. ' +
18
+ 'Returns stdout/stderr/exit/signal/error. ' +
19
+ `Commands are killed after ${DEFAULT_TIMEOUT_S}s. `,
20
+ inputSchema: executeInputSchema,
21
+ });
22
+ }
23
+ async execute(input, signal) {
24
+ const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT_S * 1000);
25
+ const combinedSignal = AbortSignal.any([signal, timeoutSignal]);
26
+ if (combinedSignal.aborted) {
27
+ return {};
28
+ }
29
+ const { command } = executeInputSchema.parse(input);
30
+ const child = spawn('bash', ['-c', command], {
31
+ detached: true,
32
+ stdio: ['ignore', 'pipe', 'pipe'],
33
+ });
34
+ const stdoutChunks = [];
35
+ const stderrChunks = [];
36
+ let error;
37
+ let closed = false;
38
+ const onAbort = () => {
39
+ if (!closed)
40
+ this.signalProcessGroup(child, 'SIGTERM');
41
+ setTimeout(() => {
42
+ if (!closed)
43
+ this.signalProcessGroup(child, 'SIGKILL');
44
+ }, TERMINATION_GRACE_MS).unref();
45
+ };
46
+ combinedSignal.addEventListener('abort', onAbort);
47
+ child.stdout.on('data', (data) => {
48
+ stdoutChunks.push(data.toString());
49
+ });
50
+ child.stderr.on('data', (data) => {
51
+ stderrChunks.push(data.toString());
52
+ });
53
+ child.on('error', (err) => {
54
+ error = err.message || String(err);
55
+ });
56
+ return new Promise((resolve) => {
57
+ child.on('close', (exit, signal) => {
58
+ closed = true;
59
+ combinedSignal.removeEventListener('abort', onAbort);
60
+ resolve({
61
+ ...(exit !== null && { exit }),
62
+ ...(signal && { signal }),
63
+ ...(error && { error }),
64
+ ...(stdoutChunks.length && { stdout: stdoutChunks.join('') }),
65
+ ...(stderrChunks.length && { stderr: stderrChunks.join('') }),
66
+ });
67
+ });
68
+ });
69
+ }
70
+ signalProcessGroup(child, signal) {
71
+ if (child.killed || !child.pid)
72
+ return;
73
+ try {
74
+ process.kill(-child.pid, signal);
75
+ }
76
+ catch (error) {
77
+ console.error(error);
78
+ }
79
+ }
80
+ }
@@ -0,0 +1 @@
1
+ export { Agent, ABORTED_MESSAGE, ERROR_MESSAGE, } from './agent.js';
@@ -0,0 +1,11 @@
1
+ import { AgentsPrompt } from './agents-prompt.js';
2
+ import { SkillsPrompt } from './skills-prompt.js';
3
+ export class InitPrompt {
4
+ async build() {
5
+ const parts = await Promise.all([
6
+ new AgentsPrompt().build(),
7
+ new SkillsPrompt().build(),
8
+ ]);
9
+ return parts.filter(Boolean).join('\n\n');
10
+ }
11
+ }
@@ -0,0 +1 @@
1
+ export {};