@ikarmic/forge-core 5.3.0 → 5.4.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/dist/agent/agent.js +132 -70
- package/dist/agent/cache-hints.d.ts +2 -0
- package/dist/agent/cache-hints.js +18 -3
- package/dist/agent/history-adapt.d.ts +23 -0
- package/dist/agent/history-adapt.js +114 -0
- package/dist/agent/prompts.d.ts +4 -2
- package/dist/agent/prompts.js +10 -2
- package/dist/agent/tool-repair.d.ts +8 -0
- package/dist/agent/tool-repair.js +16 -0
- package/dist/catalog/models-dev.d.ts +10 -0
- package/dist/catalog/models-dev.js +12 -0
- package/dist/checkpoints/store.js +3 -1
- package/dist/context/compaction.d.ts +21 -6
- package/dist/context/compaction.js +86 -26
- package/dist/context/mask.d.ts +9 -9
- package/dist/context/mask.js +30 -1
- package/dist/format/auto-format.d.ts +22 -0
- package/dist/format/auto-format.js +39 -0
- package/dist/github/github-tools.d.ts +1 -0
- package/dist/github/github-tools.js +9 -9
- package/dist/github/pr.d.ts +34 -0
- package/dist/github/pr.js +70 -0
- package/dist/human-text.d.ts +6 -0
- package/dist/human-text.js +37 -0
- package/dist/index.d.ts +13 -8
- package/dist/index.js +10 -6
- package/dist/journey/answers-block.d.ts +2 -4
- package/dist/lsp/diagnostics.d.ts +4 -0
- package/dist/lsp/diagnostics.js +8 -2
- package/dist/lsp/index.d.ts +1 -1
- package/dist/lsp/index.js +1 -1
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +1 -1
- package/dist/mcp/json-schema-to-zod.js +2 -4
- package/dist/mcp/mcp-client.d.ts +14 -0
- package/dist/mcp/mcp-client.js +102 -18
- package/dist/orchestrator/specialists-registry.js +14 -1
- package/dist/orchestrator/specialists.js +3 -3
- package/dist/semantic/embedder.d.ts +11 -0
- package/dist/semantic/embedder.js +16 -0
- package/dist/semantic/semantic-index.d.ts +39 -1
- package/dist/semantic/semantic-index.js +43 -13
- package/dist/semantic/vector-store.d.ts +2 -0
- package/dist/semantic/vector-store.js +13 -1
- package/dist/sessions/index.d.ts +2 -2
- package/dist/sessions/index.js +1 -1
- package/dist/sessions/search.js +2 -2
- package/dist/sessions/session-export.d.ts +1 -1
- package/dist/sessions/session-export.js +41 -33
- package/dist/sessions/session-store.d.ts +20 -1
- package/dist/sessions/session-store.js +7 -0
- package/dist/sessions/watch.js +4 -4
- package/dist/skills/activator.d.ts +6 -0
- package/dist/skills/activator.js +17 -0
- package/dist/skills/skill-tool.d.ts +12 -0
- package/dist/skills/skill-tool.js +28 -0
- package/dist/tools/background-process.d.ts +63 -0
- package/dist/tools/background-process.js +150 -0
- package/dist/tools/edit-file.js +2 -27
- package/dist/tools/edit-match.d.ts +16 -0
- package/dist/tools/edit-match.js +115 -0
- package/dist/tools/fetch-url.d.ts +15 -1
- package/dist/tools/fetch-url.js +7 -47
- package/dist/tools/get-diagnostics.d.ts +14 -0
- package/dist/tools/get-diagnostics.js +44 -0
- package/dist/tools/glob-filter.d.ts +7 -0
- package/dist/tools/glob-filter.js +12 -0
- package/dist/tools/html-to-markdown.d.ts +13 -0
- package/dist/tools/html-to-markdown.js +507 -0
- package/dist/tools/index.d.ts +5 -1
- package/dist/tools/index.js +11 -1
- package/dist/tools/list-files.d.ts +1 -0
- package/dist/tools/list-files.js +17 -5
- package/dist/tools/list-symbols.js +2 -2
- package/dist/tools/network-policy.d.ts +5 -0
- package/dist/tools/network-policy.js +77 -0
- package/dist/tools/office-docs.d.ts +6 -0
- package/dist/tools/office-docs.js +393 -0
- package/dist/tools/permission-rules.js +55 -5
- package/dist/tools/read-file.d.ts +2 -0
- package/dist/tools/read-file.js +47 -10
- package/dist/tools/read-image.js +2 -2
- package/dist/tools/read-only.d.ts +1 -1
- package/dist/tools/read-only.js +4 -0
- package/dist/tools/repo-clone.d.ts +40 -0
- package/dist/tools/repo-clone.js +218 -0
- package/dist/tools/result-cap.d.ts +8 -0
- package/dist/tools/result-cap.js +44 -0
- package/dist/tools/run-command.d.ts +18 -1
- package/dist/tools/run-command.js +41 -16
- package/dist/tools/search-files.js +5 -5
- package/dist/tools/search-worker.js +11 -2
- package/dist/tools/shared.d.ts +14 -1
- package/dist/tools/shared.js +76 -4
- package/dist/tools/shell.d.ts +27 -0
- package/dist/tools/shell.js +80 -0
- package/dist/tools/sub-agent.js +3 -1
- package/dist/tools/suggest-plan.d.ts +18 -0
- package/dist/tools/suggest-plan.js +28 -0
- package/dist/types.d.ts +74 -0
- package/dist/wakeup/wakeup-tools.d.ts +22 -0
- package/dist/wakeup/wakeup-tools.js +34 -0
- package/dist/wakeup/wakeup.d.ts +75 -0
- package/dist/wakeup/wakeup.js +151 -0
- package/dist/worktree/health.d.ts +51 -0
- package/dist/worktree/health.js +186 -0
- package/dist/worktree/index.d.ts +4 -0
- package/dist/worktree/index.js +2 -0
- package/dist/worktree/setup.d.ts +51 -0
- package/dist/worktree/setup.js +173 -0
- package/dist/worktree/worktree.d.ts +7 -0
- package/dist/worktree/worktree.js +9 -4
- package/package.json +1 -1
|
@@ -49,7 +49,7 @@ Stop when: you have read enough to name every file and function in the plan. Do
|
|
|
49
49
|
|
|
50
50
|
Never: write or edit files, run commands, or plan around code you have not read. If the request is unclear and the answer would change the plan, put it under Questions instead of guessing.`,
|
|
51
51
|
allowedTools: ['read_file', 'list_files', 'search_files', 'find_symbol', 'find_references', 'list_symbols', 'git_diff', 'memory_read', 'todo_write'],
|
|
52
|
-
forbiddenTools: ['write_file', 'edit_file', 'apply_patch', 'edit_notebook', 'run_command', 'sub_agent'],
|
|
52
|
+
forbiddenTools: ['write_file', 'edit_file', 'apply_patch', 'edit_notebook', 'run_command', 'bg_start', 'repo_clone', 'suggest_plan', 'sub_agent'],
|
|
53
53
|
maxIterations: 25,
|
|
54
54
|
auto: false,
|
|
55
55
|
},
|
|
@@ -129,7 +129,7 @@ Output:
|
|
|
129
129
|
|
|
130
130
|
Never: change files, run commands, or state something about this code that you did not read.`,
|
|
131
131
|
allowedTools: ['read_file', 'list_files', 'search_files', 'find_symbol', 'find_references', 'list_symbols', 'fetch_url', 'memory_read'],
|
|
132
|
-
forbiddenTools: ['write_file', 'edit_file', 'apply_patch', 'edit_notebook', 'run_command', 'sub_agent'],
|
|
132
|
+
forbiddenTools: ['write_file', 'edit_file', 'apply_patch', 'edit_notebook', 'run_command', 'bg_start', 'repo_clone', 'suggest_plan', 'sub_agent'],
|
|
133
133
|
maxIterations: 15,
|
|
134
134
|
auto: false,
|
|
135
135
|
},
|
|
@@ -156,7 +156,7 @@ Report at most ten findings. End with one line: how many hunks you reviewed and
|
|
|
156
156
|
|
|
157
157
|
Never: change files, run commands, or report something you did not confirm in the code.`,
|
|
158
158
|
allowedTools: ['read_file', 'list_files', 'search_files', 'find_symbol', 'find_references', 'list_symbols', 'git_diff', 'memory_read'],
|
|
159
|
-
forbiddenTools: ['write_file', 'edit_file', 'apply_patch', 'edit_notebook', 'run_command', 'sub_agent'],
|
|
159
|
+
forbiddenTools: ['write_file', 'edit_file', 'apply_patch', 'edit_notebook', 'run_command', 'bg_start', 'repo_clone', 'suggest_plan', 'sub_agent'],
|
|
160
160
|
maxIterations: 20,
|
|
161
161
|
auto: false,
|
|
162
162
|
},
|
|
@@ -12,7 +12,18 @@ export interface Embedder {
|
|
|
12
12
|
/** Stable identity. Vectors from different ids are never compared; a change rebuilds the index. */
|
|
13
13
|
id: string;
|
|
14
14
|
embed(texts: string[]): Promise<number[][]>;
|
|
15
|
+
/** Where the text goes, when it leaves this process: absent for the offline embedder. */
|
|
16
|
+
remote?: {
|
|
17
|
+
host: string;
|
|
18
|
+
model: string;
|
|
19
|
+
local: boolean;
|
|
20
|
+
};
|
|
15
21
|
}
|
|
22
|
+
/** The host and model that would receive chunks of code, or undefined when nothing leaves this machine (F12). */
|
|
23
|
+
export declare function embedderDestination(e: Embedder): {
|
|
24
|
+
host: string;
|
|
25
|
+
model: string;
|
|
26
|
+
} | undefined;
|
|
16
27
|
export declare function cosine(a: number[], b: number[]): number;
|
|
17
28
|
/** Lowercased sub-tokens: splits camelCase, snake_case, digits and punctuation. */
|
|
18
29
|
export declare function tokenize(text: string): string[];
|
|
@@ -8,6 +8,21 @@
|
|
|
8
8
|
* `/v1`, LM Studio, vLLM...). Opt-in via `embedderFromEnv`; chunk text is sent
|
|
9
9
|
* to that endpoint (after secret redaction).
|
|
10
10
|
*/
|
|
11
|
+
/** The host and model that would receive chunks of code, or undefined when nothing leaves this machine (F12). */
|
|
12
|
+
export function embedderDestination(e) {
|
|
13
|
+
return e.remote && !e.remote.local ? { host: e.remote.host, model: e.remote.model } : undefined;
|
|
14
|
+
}
|
|
15
|
+
function remoteOf(baseUrl, model) {
|
|
16
|
+
let host = baseUrl;
|
|
17
|
+
try {
|
|
18
|
+
host = new URL(baseUrl).hostname.replace(/^\[|\]$/g, '');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// an address that is not a URL: report it as written, and treat it as remote
|
|
22
|
+
}
|
|
23
|
+
const local = host === 'localhost' || host === '::1' || /^127\./.test(host);
|
|
24
|
+
return { host, model, local };
|
|
25
|
+
}
|
|
11
26
|
export function cosine(a, b) {
|
|
12
27
|
let dot = 0;
|
|
13
28
|
let na = 0;
|
|
@@ -62,6 +77,7 @@ export function openAiCompatibleEmbedder(options) {
|
|
|
62
77
|
const batch = options.batchSize ?? 64;
|
|
63
78
|
return {
|
|
64
79
|
id: `openai-compat:${base}:${options.model}`,
|
|
80
|
+
remote: remoteOf(base, options.model),
|
|
65
81
|
async embed(texts) {
|
|
66
82
|
const out = [];
|
|
67
83
|
for (let i = 0; i < texts.length; i += batch) {
|
|
@@ -1,10 +1,48 @@
|
|
|
1
1
|
import { type Embedder } from './embedder.js';
|
|
2
|
-
import { type SemanticManifest } from './vector-store.js';
|
|
2
|
+
import { deleteSemanticIndex, type SemanticManifest } from './vector-store.js';
|
|
3
|
+
export { deleteSemanticIndex };
|
|
3
4
|
export interface SemanticOptions {
|
|
4
5
|
/** File extensions to index, with the dot. Default: the common source and Markdown ones. */
|
|
5
6
|
extensions?: readonly string[];
|
|
6
7
|
/** Hits scoring below this (cosine similarity) are left out. Default: none cut. */
|
|
7
8
|
minScore?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Asked before anything is sent to a remote embedder (not for the offline one, nor for a server on this machine). A no
|
|
11
|
+
* stops the index before a byte leaves. Absent: no question (the terminal app, where the person set the variable).
|
|
12
|
+
*/
|
|
13
|
+
consent?: (destination: {
|
|
14
|
+
host: string;
|
|
15
|
+
model: string;
|
|
16
|
+
}) => boolean | Promise<boolean>;
|
|
17
|
+
/** Progress and outcome of an index run, for a status item. */
|
|
18
|
+
onStatus?: (status: SemanticStatus) => void;
|
|
19
|
+
}
|
|
20
|
+
export type SemanticStatus = {
|
|
21
|
+
phase: 'indexing';
|
|
22
|
+
done: number;
|
|
23
|
+
total: number;
|
|
24
|
+
} | {
|
|
25
|
+
phase: 'ready';
|
|
26
|
+
files: number;
|
|
27
|
+
chunks: number;
|
|
28
|
+
} | {
|
|
29
|
+
phase: 'error';
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
/** The person did not allow a remote embedder: search by meaning is off. */
|
|
33
|
+
| {
|
|
34
|
+
phase: 'off';
|
|
35
|
+
};
|
|
36
|
+
/** The person did not allow sending code to a remote embedder; the message tells the model what to do instead. */
|
|
37
|
+
export declare class SemanticConsentError extends Error {
|
|
38
|
+
readonly destination: {
|
|
39
|
+
host: string;
|
|
40
|
+
model: string;
|
|
41
|
+
};
|
|
42
|
+
constructor(destination: {
|
|
43
|
+
host: string;
|
|
44
|
+
model: string;
|
|
45
|
+
});
|
|
8
46
|
}
|
|
9
47
|
/** `"ts, .py,md"` as `['.ts', '.py', '.md']`; undefined (use the defaults) when nothing usable is left. */
|
|
10
48
|
export declare function parseExtensions(text: string | undefined): string[] | undefined;
|
|
@@ -4,9 +4,19 @@ import { redact } from '../tools/redact.js';
|
|
|
4
4
|
import { IgnoreRules } from '../tools/ignore-rules.js';
|
|
5
5
|
import { DEFAULT_IGNORED_DIRS } from '../tools/shared.js';
|
|
6
6
|
import { chunkSource } from './chunker.js';
|
|
7
|
-
import { cosine } from './embedder.js';
|
|
8
|
-
import { decodeVector, encodeVector, loadSemanticManifest, saveSemanticManifest } from './vector-store.js';
|
|
7
|
+
import { cosine, embedderDestination } from './embedder.js';
|
|
8
|
+
import { decodeVector, deleteSemanticIndex, encodeVector, loadSemanticManifest, saveSemanticManifest } from './vector-store.js';
|
|
9
|
+
export { deleteSemanticIndex };
|
|
9
10
|
const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java', '.md']);
|
|
11
|
+
/** The person did not allow sending code to a remote embedder; the message tells the model what to do instead. */
|
|
12
|
+
export class SemanticConsentError extends Error {
|
|
13
|
+
destination;
|
|
14
|
+
constructor(destination) {
|
|
15
|
+
super(`Search by meaning is off: the person did not allow sending code to ${destination.host} (${destination.model}). Use search_files instead.`);
|
|
16
|
+
this.destination = destination;
|
|
17
|
+
this.name = 'SemanticConsentError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
10
20
|
/** `"ts, .py,md"` as `['.ts', '.py', '.md']`; undefined (use the defaults) when nothing usable is left. */
|
|
11
21
|
export function parseExtensions(text) {
|
|
12
22
|
const list = (text ?? '')
|
|
@@ -50,21 +60,36 @@ async function collect(cwd, extensions) {
|
|
|
50
60
|
}
|
|
51
61
|
/** Bring `.forge/index/embeddings.json` up to date: re-embed only changed files, drop deleted ones. */
|
|
52
62
|
export async function ensureSemanticFresh(cwd, embedder, options = {}) {
|
|
63
|
+
// Nothing is read, chunked or sent before the person has agreed to where it would go (F12).
|
|
64
|
+
const destination = embedderDestination(embedder);
|
|
65
|
+
if (destination && options.consent && !(await options.consent(destination))) {
|
|
66
|
+
options.onStatus?.({ phase: 'off' });
|
|
67
|
+
throw new SemanticConsentError(destination);
|
|
68
|
+
}
|
|
53
69
|
const manifest = await loadSemanticManifest(cwd, embedder.id);
|
|
54
70
|
const files = await collect(cwd, options.extensions ? new Set(options.extensions) : EXTENSIONS);
|
|
55
71
|
const seen = new Set(files.map((f) => f.rel));
|
|
56
72
|
let changed = false;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
chunks
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
const pending = files.filter((f) => manifest.files[f.rel]?.mtimeMs !== f.mtimeMs);
|
|
74
|
+
let done = 0;
|
|
75
|
+
if (pending.length > 0)
|
|
76
|
+
options.onStatus?.({ phase: 'indexing', done, total: pending.length });
|
|
77
|
+
try {
|
|
78
|
+
for (const f of pending) {
|
|
79
|
+
const chunks = await chunkSource(await readFile(f.abs, 'utf8'), f.ext);
|
|
80
|
+
// Secrets never reach an embedding endpoint or the on-disk index.
|
|
81
|
+
const vectors = chunks.length ? await embedder.embed(chunks.map((c) => redact(c.text).text)) : [];
|
|
82
|
+
manifest.files[f.rel] = {
|
|
83
|
+
mtimeMs: f.mtimeMs,
|
|
84
|
+
chunks: chunks.map((c, i) => ({ s: c.startLine, e: c.endLine, v: encodeVector(vectors[i]) })),
|
|
85
|
+
};
|
|
86
|
+
changed = true;
|
|
87
|
+
options.onStatus?.({ phase: 'indexing', done: ++done, total: pending.length });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
options.onStatus?.({ phase: 'error', message: `Indexing stopped${destination ? ` while talking to ${destination.host}` : ''}: ${err.message}` });
|
|
92
|
+
throw err;
|
|
68
93
|
}
|
|
69
94
|
for (const rel of Object.keys(manifest.files)) {
|
|
70
95
|
if (!seen.has(rel)) {
|
|
@@ -74,6 +99,11 @@ export async function ensureSemanticFresh(cwd, embedder, options = {}) {
|
|
|
74
99
|
}
|
|
75
100
|
if (changed)
|
|
76
101
|
await saveSemanticManifest(cwd, manifest);
|
|
102
|
+
options.onStatus?.({
|
|
103
|
+
phase: 'ready',
|
|
104
|
+
files: Object.keys(manifest.files).length,
|
|
105
|
+
chunks: Object.values(manifest.files).reduce((n, f) => n + f.chunks.length, 0),
|
|
106
|
+
});
|
|
77
107
|
return manifest;
|
|
78
108
|
}
|
|
79
109
|
export async function searchSemantic(cwd, embedder, query, k = 8, options = {}) {
|
|
@@ -16,4 +16,6 @@ export interface SemanticManifest {
|
|
|
16
16
|
export declare function encodeVector(v: number[]): string;
|
|
17
17
|
export declare function decodeVector(s: string): number[];
|
|
18
18
|
export declare function loadSemanticManifest(cwd: string, embedderId: string): Promise<SemanticManifest>;
|
|
19
|
+
/** Delete the index file. True when there was one. */
|
|
20
|
+
export declare function deleteSemanticIndex(cwd: string): Promise<boolean>;
|
|
19
21
|
export declare function saveSemanticManifest(cwd: string, manifest: SemanticManifest): Promise<void>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
export const EMBEDDINGS_FILE = '.forge/index/embeddings.json';
|
|
4
4
|
export function encodeVector(v) {
|
|
@@ -20,6 +20,18 @@ export async function loadSemanticManifest(cwd, embedderId) {
|
|
|
20
20
|
}
|
|
21
21
|
return { version: 1, embedderId, files: {} };
|
|
22
22
|
}
|
|
23
|
+
/** Delete the index file. True when there was one. */
|
|
24
|
+
export async function deleteSemanticIndex(cwd) {
|
|
25
|
+
const file = path.join(cwd, EMBEDDINGS_FILE);
|
|
26
|
+
try {
|
|
27
|
+
await readFile(file);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
await rm(file, { force: true });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
23
35
|
export async function saveSemanticManifest(cwd, manifest) {
|
|
24
36
|
const file = path.join(cwd, EMBEDDINGS_FILE);
|
|
25
37
|
await mkdir(path.dirname(file), { recursive: true });
|
package/dist/sessions/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { defaultSessionDir, generateSessionId, saveSession, loadSession, deleteSession, listSessions, findLatestSession, } from './session-store.js';
|
|
2
|
-
export type { Session, ListEntry } from './session-store.js';
|
|
1
|
+
export { defaultSessionDir, generateSessionId, saveSession, loadSession, deleteSession, displayMessages, listSessions, findLatestSession, } from './session-store.js';
|
|
2
|
+
export type { Session, ListEntry, MessageFeedback } from './session-store.js';
|
|
3
3
|
export { pruneByRetention, pruneSessions, type SessionPruneOptions, type SessionPruneResult } from './prune.js';
|
|
4
4
|
export { formatSessionMarkdown, formatSessionJson, type FormatOptions } from './session-export.js';
|
|
5
5
|
export { appendedSince, summarizeMessage, watchSession } from './watch.js';
|
package/dist/sessions/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { defaultSessionDir, generateSessionId, saveSession, loadSession, deleteSession, listSessions, findLatestSession, } from './session-store.js';
|
|
1
|
+
export { defaultSessionDir, generateSessionId, saveSession, loadSession, deleteSession, displayMessages, listSessions, findLatestSession, } from './session-store.js';
|
|
2
2
|
export { pruneByRetention, pruneSessions } from './prune.js';
|
|
3
3
|
export { formatSessionMarkdown, formatSessionJson } from './session-export.js';
|
|
4
4
|
export { appendedSince, summarizeMessage, watchSession } from './watch.js';
|
package/dist/sessions/search.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* assistant message (tool calls, tool results and attachment bytes are skipped).
|
|
4
4
|
* Every word of the query must appear; results rank by how often they do, then by recency.
|
|
5
5
|
*/
|
|
6
|
-
import { listSessions, loadSession } from './session-store.js';
|
|
6
|
+
import { displayMessages, listSessions, loadSession } from './session-store.js';
|
|
7
7
|
/** The searchable text of a message list: user and assistant text parts. */
|
|
8
8
|
export function sessionText(messages) {
|
|
9
9
|
const out = [];
|
|
@@ -49,7 +49,7 @@ export async function searchSessions(dir, query, options = {}) {
|
|
|
49
49
|
let text;
|
|
50
50
|
try {
|
|
51
51
|
const session = await loadSession(entry.id, dir);
|
|
52
|
-
text = `${entry.label ?? ''}\n${sessionText(session
|
|
52
|
+
text = `${entry.label ?? ''}\n${sessionText(displayMessages(session))}`;
|
|
53
53
|
}
|
|
54
54
|
catch {
|
|
55
55
|
continue;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* - Long strings (tool results, file contents) are truncated with
|
|
17
17
|
* a "(truncated)" footer so the export stays readable.
|
|
18
18
|
*/
|
|
19
|
-
import type
|
|
19
|
+
import { type Session } from './session-store.js';
|
|
20
20
|
export interface FormatOptions {
|
|
21
21
|
/** Max characters per field (tool result, message content). Default 4000. */
|
|
22
22
|
maxField?: number;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* a "(truncated)" footer so the export stays readable.
|
|
18
18
|
*/
|
|
19
19
|
import { stripSteering } from '../steering/select.js';
|
|
20
|
+
import { displayMessages } from './session-store.js';
|
|
20
21
|
/** Maximum length of any single string field in the output before truncation. */
|
|
21
22
|
const DEFAULT_MAX_FIELD = 4000;
|
|
22
23
|
/** Hard cap on total output length: anything longer gets a final "(truncated)". */
|
|
@@ -54,7 +55,8 @@ export function formatSessionMarkdown(session, options = {}) {
|
|
|
54
55
|
out.push('');
|
|
55
56
|
// Body: walk messages, grouping tool results under their parent assistant turn.
|
|
56
57
|
let pendingToolResults = [];
|
|
57
|
-
|
|
58
|
+
// Deep redaction first: a secret-named field (`{ "api_key": ... }`) is invisible to the text patterns below (RB-59).
|
|
59
|
+
for (const msg of displayMessages(redact ? redactSession(session) : session)) {
|
|
58
60
|
if (msg.role === 'user') {
|
|
59
61
|
// Injected steering is agent context, not something the user typed.
|
|
60
62
|
const content = withoutSteering(msg.content);
|
|
@@ -281,41 +283,47 @@ function redactSession(session) {
|
|
|
281
283
|
// content field is a discriminated union of string/array shapes and
|
|
282
284
|
// TS won't let us reassign across variants when we spread the
|
|
283
285
|
// original message.
|
|
284
|
-
const
|
|
286
|
+
const redactAll = (list) => list.map((msg) => {
|
|
285
287
|
const m = msg;
|
|
286
|
-
|
|
287
|
-
return { ...m, content: next };
|
|
288
|
+
return { ...m, content: redactContent(m.content) };
|
|
288
289
|
});
|
|
289
|
-
|
|
290
|
+
// The transcript holds everything said before compaction too: it is redacted the same way (RB-58).
|
|
291
|
+
// A note on a rating is text the person typed: it can hold a secret too (F3).
|
|
292
|
+
const feedback = session.feedback
|
|
293
|
+
? Object.fromEntries(Object.entries(session.feedback).map(([turn, f]) => [turn, f.note ? { ...f, note: redactString(f.note, true) } : f]))
|
|
294
|
+
: undefined;
|
|
295
|
+
return {
|
|
296
|
+
...session,
|
|
297
|
+
messages: redactAll(session.messages),
|
|
298
|
+
...(session.transcript ? { transcript: redactAll(session.transcript) } : {}),
|
|
299
|
+
...(feedback ? { feedback } : {}),
|
|
300
|
+
};
|
|
290
301
|
}
|
|
291
302
|
function redactContent(content) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
if (typeof out.input === 'string')
|
|
318
|
-
out.input = redactString(out.input, true);
|
|
303
|
+
return redactDeep(content);
|
|
304
|
+
}
|
|
305
|
+
/** Field names whose string value is a secret whatever it looks like (`{ "password": "hunter2" }`). */
|
|
306
|
+
const SECRET_KEY = /^(password|passwd|pwd|api[_-]?key|secret|token|access[_-]?token|auth(orization)?)$/i;
|
|
307
|
+
/** Fields that name things rather than carry text; never rewritten. */
|
|
308
|
+
const STRUCTURAL_KEYS = new Set(['type', 'role', 'toolCallId', 'toolName', 'mediaType']);
|
|
309
|
+
/**
|
|
310
|
+
* Redact every string inside a message's content, at any depth (RB-59): tool call `input` and tool result
|
|
311
|
+
* `output.value` are objects in the AI SDK shape, and a field list missed them. Walking values, not a JSON string,
|
|
312
|
+
* keeps the shape intact.
|
|
313
|
+
*/
|
|
314
|
+
function redactDeep(value, key) {
|
|
315
|
+
if (typeof value === 'string') {
|
|
316
|
+
if (key && SECRET_KEY.test(key))
|
|
317
|
+
return '***REDACTED***';
|
|
318
|
+
return key && STRUCTURAL_KEYS.has(key) ? value : redactString(value, true);
|
|
319
|
+
}
|
|
320
|
+
if (Array.isArray(value))
|
|
321
|
+
return value.map((v) => redactDeep(v));
|
|
322
|
+
if (value && typeof value === 'object') {
|
|
323
|
+
const out = {};
|
|
324
|
+
for (const [k, v] of Object.entries(value))
|
|
325
|
+
out[k] = redactDeep(v, k);
|
|
319
326
|
return out;
|
|
320
|
-
}
|
|
327
|
+
}
|
|
328
|
+
return value;
|
|
321
329
|
}
|
|
@@ -20,8 +20,18 @@ export interface Session {
|
|
|
20
20
|
cwd: string;
|
|
21
21
|
/** Model used for the most recent turn (informational). */
|
|
22
22
|
model: ModelConfig;
|
|
23
|
-
/** Conversation history at the time of the last save. */
|
|
23
|
+
/** Conversation history at the time of the last save: what the next turn sends, compacted. */
|
|
24
24
|
messages: ModelMessage[];
|
|
25
|
+
/**
|
|
26
|
+
* Every message shown in the chat, never compacted (RB-58). Absent in sessions saved before it: use
|
|
27
|
+
* {@link displayMessages}, which falls back to `messages`.
|
|
28
|
+
*/
|
|
29
|
+
transcript?: ModelMessage[];
|
|
30
|
+
/**
|
|
31
|
+
* Ratings the person gave replies, keyed by turn number (1 = the first message). Kept here only: nothing sends it anywhere.
|
|
32
|
+
* Must be listed in `sessionSchema` below, or every load drops it (RB-58).
|
|
33
|
+
*/
|
|
34
|
+
feedback?: Record<string, MessageFeedback>;
|
|
25
35
|
/** Token usage accrued across the session, if known. */
|
|
26
36
|
totalInputTokens?: number;
|
|
27
37
|
totalOutputTokens?: number;
|
|
@@ -30,6 +40,13 @@ export interface Session {
|
|
|
30
40
|
/** Whether pricing was resolved for the session's model. */
|
|
31
41
|
costKnown?: boolean;
|
|
32
42
|
}
|
|
43
|
+
/** A thumbs up or down on one turn's reply, with an optional note on a down. */
|
|
44
|
+
export interface MessageFeedback {
|
|
45
|
+
rating: 'up' | 'down';
|
|
46
|
+
note?: string;
|
|
47
|
+
/** ISO-8601 time of the rating. */
|
|
48
|
+
at: string;
|
|
49
|
+
}
|
|
33
50
|
/** Default session directory: `~/.forge/sessions/`. */
|
|
34
51
|
export declare function defaultSessionDir(env?: NodeJS.ProcessEnv): string;
|
|
35
52
|
/** Generate a fresh session id like `2026-09-24T18-04-01Z-3f9c2a`. */
|
|
@@ -62,3 +79,5 @@ export declare function deleteSession(id: string, dir: string): Promise<void>;
|
|
|
62
79
|
export declare function listSessions(dir: string): Promise<ListEntry[]>;
|
|
63
80
|
/** Most-recent session, or null if there are none. */
|
|
64
81
|
export declare function findLatestSession(dir: string): Promise<ListEntry | null>;
|
|
82
|
+
/** The messages to show for a session: the full transcript, or `messages` for a file saved before RB-58. */
|
|
83
|
+
export declare function displayMessages(session: Pick<Session, 'messages' | 'transcript'>): ModelMessage[];
|
|
@@ -15,6 +15,9 @@ const sessionSchema = z.object({
|
|
|
15
15
|
model: z.string(),
|
|
16
16
|
}),
|
|
17
17
|
messages: z.array(z.unknown()),
|
|
18
|
+
// zod drops keys it does not list: without this line every load lost the transcript (RB-58).
|
|
19
|
+
transcript: z.array(z.unknown()).optional(),
|
|
20
|
+
feedback: z.record(z.string(), z.object({ rating: z.enum(['up', 'down']), note: z.string().optional(), at: z.string() })).optional(),
|
|
18
21
|
totalInputTokens: z.number().optional(),
|
|
19
22
|
totalOutputTokens: z.number().optional(),
|
|
20
23
|
totalCostUsd: z.number().optional(),
|
|
@@ -181,3 +184,7 @@ async function rename(from, to) {
|
|
|
181
184
|
// Suppress unused-import warning on rare Node versions.
|
|
182
185
|
void mkdtemp;
|
|
183
186
|
void rm;
|
|
187
|
+
/** The messages to show for a session: the full transcript, or `messages` for a file saved before RB-58. */
|
|
188
|
+
export function displayMessages(session) {
|
|
189
|
+
return session.transcript ?? session.messages;
|
|
190
|
+
}
|
package/dist/sessions/watch.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { stat } from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { loadSession } from './session-store.js';
|
|
8
|
+
import { displayMessages, loadSession } from './session-store.js';
|
|
9
9
|
/** Starts following session `id`; returns a function that stops it. */
|
|
10
10
|
export function watchSession(id, dir, onUpdate, opts = {}) {
|
|
11
11
|
const file = path.join(dir, `${id}.json`);
|
|
@@ -28,11 +28,11 @@ export function watchSession(id, dir, onUpdate, opts = {}) {
|
|
|
28
28
|
if (stopped)
|
|
29
29
|
return;
|
|
30
30
|
if (known === undefined) {
|
|
31
|
-
known = session
|
|
31
|
+
known = displayMessages(session);
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
|
-
const appended = appendedSince(known, session
|
|
35
|
-
known = session
|
|
34
|
+
const appended = appendedSince(known, displayMessages(session));
|
|
35
|
+
known = displayMessages(session);
|
|
36
36
|
if (appended === null || appended.length > 0)
|
|
37
37
|
onUpdate({ session, appended });
|
|
38
38
|
}
|
|
@@ -43,6 +43,12 @@ export declare function selectSkills(skills: Skill[], task: string, maxSkills?:
|
|
|
43
43
|
* The output is plain text: it's appended to the system prompt verbatim.
|
|
44
44
|
*/
|
|
45
45
|
export declare function buildSkillsBlock(skills: Skill[]): string;
|
|
46
|
+
/**
|
|
47
|
+
* Every skill by name and one line, sorted by name so the block does not change between turns (the prompt cache
|
|
48
|
+
* depends on it) and does not depend on the task. The keyword pick above still carries full bodies. Text from a
|
|
49
|
+
* skill file can come from a cloned repository, so angle brackets are dropped and the closing tag cannot be forged.
|
|
50
|
+
*/
|
|
51
|
+
export declare function buildSkillIndex(skills: Skill[]): string;
|
|
46
52
|
/**
|
|
47
53
|
* Compose the full skills section that gets prepended to the system
|
|
48
54
|
* prompt. Includes a short preamble explaining how to interpret skills.
|
package/dist/skills/activator.js
CHANGED
|
@@ -101,6 +101,23 @@ export function buildSkillsBlock(skills) {
|
|
|
101
101
|
}
|
|
102
102
|
return `<skills>\n${sections.join('\n\n---\n\n')}\n</skills>`;
|
|
103
103
|
}
|
|
104
|
+
const INDEX_MAX_ENTRIES = 60;
|
|
105
|
+
const INDEX_DESCRIPTION_CHARS = 140;
|
|
106
|
+
/**
|
|
107
|
+
* Every skill by name and one line, sorted by name so the block does not change between turns (the prompt cache
|
|
108
|
+
* depends on it) and does not depend on the task. The keyword pick above still carries full bodies. Text from a
|
|
109
|
+
* skill file can come from a cloned repository, so angle brackets are dropped and the closing tag cannot be forged.
|
|
110
|
+
*/
|
|
111
|
+
export function buildSkillIndex(skills) {
|
|
112
|
+
if (skills.length === 0)
|
|
113
|
+
return '';
|
|
114
|
+
const clean = (text, max) => text.split('\n')[0].replace(/[<>]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
115
|
+
const sorted = [...skills].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
116
|
+
const lines = sorted.slice(0, INDEX_MAX_ENTRIES).map((s) => `- ${clean(s.name, 60)}${s.description ? `: ${clean(s.description, INDEX_DESCRIPTION_CHARS)}` : ''}`);
|
|
117
|
+
if (sorted.length > INDEX_MAX_ENTRIES)
|
|
118
|
+
lines.push(`- ... and ${sorted.length - INDEX_MAX_ENTRIES} more`);
|
|
119
|
+
return ['These skills are installed. Load one with the `skill` tool when it fits the task.', '<available-skills>', ...lines, '</available-skills>'].join('\n');
|
|
120
|
+
}
|
|
104
121
|
/**
|
|
105
122
|
* Compose the full skills section that gets prepended to the system
|
|
106
123
|
* prompt. Includes a short preamble explaining how to interpret skills.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ToolDefinition } from '../types.js';
|
|
3
|
+
import type { SkillRegistry } from './types.js';
|
|
4
|
+
declare const params: z.ZodObject<{
|
|
5
|
+
name: z.ZodString;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
/**
|
|
8
|
+
* `skill`: load one skill's instructions. The prompt lists every skill by name, but only the ones that matched the
|
|
9
|
+
* task by keyword carry their body; this loads any other. It reads what is already loaded, so it asks nothing.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createSkillTool(registry: SkillRegistry): ToolDefinition<z.infer<typeof params>, string>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const params = z.object({ name: z.string().describe('The name of a skill from the available skills list.') });
|
|
3
|
+
/**
|
|
4
|
+
* `skill`: load one skill's instructions. The prompt lists every skill by name, but only the ones that matched the
|
|
5
|
+
* task by keyword carry their body; this loads any other. It reads what is already loaded, so it asks nothing.
|
|
6
|
+
*/
|
|
7
|
+
export function createSkillTool(registry) {
|
|
8
|
+
return {
|
|
9
|
+
name: 'skill',
|
|
10
|
+
description: 'Load the instructions of one of the available skills by name, then follow them. Use it when a skill in the list fits the task.',
|
|
11
|
+
parameters: params,
|
|
12
|
+
execute: async (args) => {
|
|
13
|
+
const wanted = args.name.trim();
|
|
14
|
+
const all = registry.list();
|
|
15
|
+
const found = registry.get(wanted) ?? all.find((s) => s.name.toLowerCase() === wanted.toLowerCase());
|
|
16
|
+
if (!found) {
|
|
17
|
+
const names = all.map((s) => s.name).sort();
|
|
18
|
+
throw new Error(`No skill named "${wanted}". Available: ${names.slice(0, 40).join(', ') || '(none)'}.`);
|
|
19
|
+
}
|
|
20
|
+
return [
|
|
21
|
+
`## Skill: ${found.name}`,
|
|
22
|
+
...(found.allowedTools?.length ? [`Allowed tools: ${found.allowedTools.join(', ')}`] : []),
|
|
23
|
+
'',
|
|
24
|
+
found.body,
|
|
25
|
+
].join('\n');
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ToolContext, ToolDefinition } from '../types.js';
|
|
3
|
+
export interface BackgroundRead {
|
|
4
|
+
id: string;
|
|
5
|
+
command: string;
|
|
6
|
+
running: boolean;
|
|
7
|
+
exitCode: number | null;
|
|
8
|
+
/** Output since the cursor, secrets removed. */
|
|
9
|
+
output: string;
|
|
10
|
+
/** Pass this as `since` to get only what comes after. */
|
|
11
|
+
nextSince: number;
|
|
12
|
+
/** Characters between the cursor and the start of `output` that were lost (buffer or read limit). */
|
|
13
|
+
droppedChars: number;
|
|
14
|
+
}
|
|
15
|
+
export declare class BackgroundProcesses {
|
|
16
|
+
private readonly entries;
|
|
17
|
+
private nextId;
|
|
18
|
+
private readonly maxBufferChars;
|
|
19
|
+
constructor(options?: {
|
|
20
|
+
maxBufferChars?: number;
|
|
21
|
+
});
|
|
22
|
+
/** How many are running now. */
|
|
23
|
+
get liveCount(): number;
|
|
24
|
+
start(command: string, cwd: string, ctx: ToolContext, cwdOverride?: string): {
|
|
25
|
+
id: string;
|
|
26
|
+
pid: number | undefined;
|
|
27
|
+
};
|
|
28
|
+
private get;
|
|
29
|
+
read(id: string, since?: number): BackgroundRead;
|
|
30
|
+
stop(id: string): {
|
|
31
|
+
id: string;
|
|
32
|
+
wasRunning: boolean;
|
|
33
|
+
};
|
|
34
|
+
/** Stop everything still running; returns what was running. */
|
|
35
|
+
stopAll(): {
|
|
36
|
+
id: string;
|
|
37
|
+
command: string;
|
|
38
|
+
}[];
|
|
39
|
+
}
|
|
40
|
+
declare const startParams: z.ZodObject<{
|
|
41
|
+
command: z.ZodString;
|
|
42
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
43
|
+
}, z.core.$strip>;
|
|
44
|
+
export declare const bgStartTool: ToolDefinition<z.infer<typeof startParams>, {
|
|
45
|
+
id: string;
|
|
46
|
+
pid: number | undefined;
|
|
47
|
+
note: string;
|
|
48
|
+
}>;
|
|
49
|
+
declare const readParams: z.ZodObject<{
|
|
50
|
+
id: z.ZodString;
|
|
51
|
+
since: z.ZodOptional<z.ZodNumber>;
|
|
52
|
+
}, z.core.$strip>;
|
|
53
|
+
export declare const bgReadTool: ToolDefinition<z.infer<typeof readParams>, BackgroundRead & {
|
|
54
|
+
note?: string;
|
|
55
|
+
}>;
|
|
56
|
+
declare const stopParams: z.ZodObject<{
|
|
57
|
+
id: z.ZodString;
|
|
58
|
+
}, z.core.$strip>;
|
|
59
|
+
export declare const bgStopTool: ToolDefinition<z.infer<typeof stopParams>, {
|
|
60
|
+
id: string;
|
|
61
|
+
wasRunning: boolean;
|
|
62
|
+
}>;
|
|
63
|
+
export {};
|