Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,8 @@ it.effect.each([
messageId,
});
}).pipe(Effect.provide(Layer.merge(layers, fixture.settingsLayer)));
assert.equal(threadReads, shouldExpand ? 1 : 0);
const locates = mode === "plain-name";
assert.equal(threadReads, shouldExpand || locates ? 1 : 0);
assert.lengthOf(received, 1);
assert.include(received[0]!, "native:review");
if (mode === "pack-failure" || mode === "settings-failure") {
Expand All @@ -617,6 +618,9 @@ it.effect.each([
else assert.notInclude(received[0]!, "Fallback:");
assert.include(received[0]!, "<t3_pstack>");
assert.notInclude(received[0]!, "$pstack:architect");
} else if (locates) {
assert.include(received[0]!, "pstack is built into T3 Code.");
assert.notInclude(received[0]!, "Resolved roles");
} else {
assert.notInclude(received[0]!, "<t3_pstack>");
if (mode !== "no-mention") assert.include(received[0]!, "pstack:architect");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as ServerSettings from "../serverSettings.ts";
import { hasPstackComposerInvocation } from "../pstack/composer.ts";
import { referencesPstack } from "../pstack/composer.ts";
import * as PstackPack from "../pstack/PstackPack.ts";
import {
collectComposerContextReferences,
Expand Down Expand Up @@ -473,7 +473,7 @@ export const layer: Layer.Layer<
}
let composer = { text: message.text, records: message.context?.records ?? [] };
if (
hasPstackComposerInvocation(composer, message) &&
referencesPstack(composer, message) &&
(yield* settings.getSettings.pipe(
Effect.map((current) => current.pstackEnabled),
// Expansion reports settings failures without preventing message delivery.
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/pstack/PstackPack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,19 @@ it.layer(NodeServices.layer)("PstackPack", (it) => {
)).text,
"$pstack:unknown",
);
// T3 turns native pstack plugins off, so a plain mention must still find T3's pack.
const mention = { ...input, text: "use pstack principles" };
const located = (yield* pack.expandComposer(mention, { createdBy: "user" })).text;
assert.include(located, `<pstack>: ${directory}`);
assert.notInclude(located, "Resolved roles");
assert.strictEqual(yield* pack.expandComposer(mention, { createdBy: "agent" }), mention);
// Codex stores a goal's text as its objective.
const goal = { ...input, text: "/goal use pstack principles" };
assert.strictEqual(yield* pack.expandComposer(goal, { createdBy: "user" }), goal);
yield* settings.updateSettings({ pstackEnabled: false });
assert.equal(yield* pack.catalog, undefined);
assert.strictEqual(yield* pack.expandComposer(input, { createdBy: "user" }), input);
assert.strictEqual(yield* pack.expandComposer(mention, { createdBy: "user" }), mention);
}).pipe(Effect.provide(Layer.merge(fixture.layer, fixture.settingsLayer)));
}),
);
Expand Down
13 changes: 10 additions & 3 deletions apps/server/src/pstack/PstackPack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
import { readOrchestrationProviders } from "../provider/orchestrationTargets.ts";
import { isProcessAlive } from "../serverRuntimeState.ts";
import { planPstackComposer } from "./composer.ts";
import { formatPstackBlock, parseModelsFile, resolveRoles } from "./pstackModels.ts";
import { planPstackComposer, referencesPstack } from "./composer.ts";
import {
formatPstackBlock,
formatPstackLocator,
parseModelsFile,
resolveRoles,
} from "./pstackModels.ts";

class PstackPackError extends Schema.TaggedError<PstackPackError>()("PstackPackError", {
path: Schema.String,
Expand Down Expand Up @@ -299,10 +304,12 @@ const make = Effect.fn("PstackPack.make")(function* (sourceRoot?: string) {
)(
function* (input, message) {
const plan = planPstackComposer(input, message);
if (plan === undefined) return input;
if (plan === undefined && !referencesPstack(input, message)) return input;
const currentSettings = yield* settings.getSettings;
if (!currentSettings.pstackEnabled) return input;
const pack = yield* readPack();
if (plan === undefined)
return { ...input, text: `${input.text.trimEnd()}\n\n${formatPstackLocator(packDir)}` };
const names = new Set(
plan.skillNames.filter((name) => pack.skills.some((skill) => skill.name === name)),
);
Expand Down
49 changes: 33 additions & 16 deletions apps/server/src/pstack/composer.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,30 @@
import type { ComposerContextRecord, OrchestrationV2ConversationMessage } from "@t3tools/contracts";
import {
type ComposerContextRecord,
type OrchestrationV2ConversationMessage,
parseGoalCommand,
} from "@t3tools/contracts";
import { PSTACK_SKILL_PREFIX } from "@t3tools/pstack";
import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";
import {
collectComposerContextReferences,
replaceComposerContextReferences,
} from "@t3tools/shared/composerContextReferences";

export function hasPstackComposerInvocation(
type ComposerMessage = Pick<
OrchestrationV2ConversationMessage,
"createdBy" | "notification" | "delegatedCompletion"
>;

const isUserAuthored = (message: ComposerMessage) =>
message.createdBy === "user" &&
message.notification === undefined &&
message.delegatedCompletion === undefined;

function hasPstackComposerInvocation(
input: { readonly text: string; readonly records: ReadonlyArray<ComposerContextRecord> },
message: Pick<
OrchestrationV2ConversationMessage,
"createdBy" | "notification" | "delegatedCompletion"
>,
message: ComposerMessage,
): boolean {
if (
message.createdBy !== "user" ||
message.notification !== undefined ||
message.delegatedCompletion !== undefined
)
return false;
if (!isUserAuthored(message)) return false;
if (
input.records.some(
(record) =>
Expand All @@ -38,10 +44,7 @@ export function planPstackComposer(
readonly text: string;
readonly records: ReadonlyArray<ComposerContextRecord>;
},
message: Pick<
OrchestrationV2ConversationMessage,
"createdBy" | "notification" | "delegatedCompletion"
>,
message: ComposerMessage,
) {
if (!hasPstackComposerInvocation(input, message)) return undefined;
const mentions = [...input.text.matchAll(SKILL_MENTION_PATTERN)]
Expand Down Expand Up @@ -98,3 +101,17 @@ export function planPstackComposer(
},
};
}

/** An invocation, or a user naming pstack without one, for example "use pstack principles". */
export function referencesPstack(
input: { readonly text: string; readonly records: ReadonlyArray<ComposerContextRecord> },
message: ComposerMessage,
): boolean {
return (
hasPstackComposerInvocation(input, message) ||
(isUserAuthored(message) &&
/\bpstack\b/i.test(input.text) &&
// A native goal's text becomes its objective, so it carries no locator.
parseGoalCommand(input) === null)
);
}
10 changes: 10 additions & 0 deletions apps/server/src/pstack/pstackModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,16 @@ export function resolveRoles(input: {
return { roles, diagnostics };
}

// Native pstack plugins are off in T3 sessions, so this is the agent's only pointer to the pack.
export function formatPstackLocator(packDir: string): string {
return [
"<t3_pstack>",
`pstack is built into T3 Code. <pstack>: ${packDir}`,
"Read <pstack>/skills/<name>/SKILL.md for a pstack skill; its principles are the principle-* skills. Running a workflow with T3's model roles needs $pstack:<skill>.",
"</t3_pstack>",
].join("\n");
}

export function formatPstackBlock(input: {
readonly skillNames: ReadonlyArray<string>;
readonly packDir: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1126,7 +1126,7 @@ it.effect.each([
assert.isEmpty(expectedSettings);
assert.isEmpty(expectedKeybindings);
const callsBeforeToggle = editorDiscoveryCalls;
for (const enabled of [true, false]) {
for (const enabled of [false, true]) {
yield* settings.updateSettings({ pstackEnabled: enabled });
const event = yield* Queue.take(responses);
assert.equal(event.type, "snapshot");
Expand Down
2 changes: 1 addition & 1 deletion docs/user/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ provider. On mobile, both are also available before starting a thread on

The slash menu also includes skills unless you turn off **Settings → Chat →
Show skills in slash menu**, under Advanced. Only skills enabled for the provider are listed.
Built-in [pstack](./pstack.md) skills, once turned on, are listed for every
Built-in [pstack](./pstack.md) skills, unless turned off, are listed for every
provider.

After you add or change skills, plugins, or MCP servers, use **Restart agent
Expand Down
9 changes: 5 additions & 4 deletions docs/user/pstack.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ and runs it through its orchestration tools across supported providers. Its mult
one `$pstack:architect` can run an Opus runner and a GPT runner side by side, whichever provider
your thread uses.

## Turn it on
## Turn it off

Open **Settings → Labs → Zack** and turn on **pstack**. The setting applies to one environment, so turn it on for each
machine you want it on. Turning it off removes the `pstack:` skills from the composer
pstack is on by default. To turn it off, open **Settings → Labs → Zack** and turn off **pstack**. The
setting applies to one environment. Turning it off removes the `pstack:` skills from the composer
and keeps your model choices.

## Choose models once
Expand All @@ -30,6 +30,7 @@ Type `$` or `/` in the composer and pick a `pstack:` skill, for example `$pstack
cache layer`. Start rigorous work with `$pstack:poteto-mode`; it picks the other skills as it goes.
Helper agents appear under the thread as tasks you can open, follow, or stop.
Use `$pstack:automate-maintainer` or `$pstack:automate-team` to capture a maintainer’s or team’s engineering conventions.
Mentioning pstack without a skill, such as “use pstack principles”, points the agent at T3 Code’s copy so it can read the skills, but runs no workflow.

When a model becomes unavailable, pools use their remaining choices; if none remain, they use your
thread’s model. Panels keep every seat, using your thread’s model for unavailable entries. The reply
Expand All @@ -39,7 +40,7 @@ the current capabilities before choosing another target.

## Good to know

- pstack runs on the machine the thread runs on. A linked machine needs pstack turned on and its own
- pstack runs on the machine the thread runs on. A linked machine needs pstack on and its own
`$pstack:setup-pstack`, since its providers differ.
- OpenCode sessions using an external **Server URL** do not expose T3's orchestration tools. Leave
that setting blank to let T3 manage the server before running a pstack workflow.
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1496,7 +1496,7 @@ export const ServerSettings = Schema.Struct({

enableAgentDeviceAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
/** Offer the bundled pstack skills in every provider composer. */
pstackEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
pstackEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
/** Off by default: turning it on moves skill folders out of the user's Claude and Codex homes. */
skillsLibraryEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
/** On by default: each settled run costs a text generation call unless its project opts out. */
Expand Down
Loading