portable-agent-layer 0.78.2 → 0.79.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/README.md +3 -2
- package/assets/schema/pal-settings.schema.json +0 -1
- package/assets/skills/pal-reflect/SKILL.md +2 -2
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +4 -4
- package/assets/templates/pal-settings.json +0 -1
- package/package.json +2 -2
- package/src/cli/builtin-tools.ts +1 -0
- package/src/cli/index.ts +3 -48
- package/src/cli/setup-auto-update.ts +7 -7
- package/src/hooks/AutoUpdate.ts +1 -1
- package/src/hooks/LoadContext.ts +13 -1
- package/src/hooks/PreCompactPersist.ts +2 -2
- package/src/hooks/handlers/persist-last-exchange.ts +128 -33
- package/src/hooks/handlers/session-intelligence.ts +53 -14
- package/src/hooks/lib/algorithm-review.ts +12 -7
- package/src/hooks/lib/auto-update.ts +16 -6
- package/src/hooks/lib/context.ts +18 -56
- package/src/hooks/lib/graduation.ts +2 -2
- package/src/hooks/lib/handoff-context.ts +112 -0
- package/src/hooks/lib/inference.ts +12 -45
- package/src/hooks/lib/log.ts +44 -3
- package/src/hooks/lib/paths.ts +7 -2
- package/src/hooks/lib/projects.ts +16 -4
- package/src/hooks/lib/relationship.ts +2 -2
- package/src/hooks/lib/retrieval-index.ts +13 -6
- package/src/hooks/lib/security.ts +29 -3
- package/src/hooks/lib/semi-static.ts +10 -3
- package/src/hooks/lib/session-context.ts +22 -0
- package/src/hooks/lib/session-dir.ts +9 -0
- package/src/hooks/lib/settings.ts +11 -12
- package/src/hooks/lib/work-tracking.ts +9 -1
- package/src/tools/{relationship-reflect.ts → agent/relationship-reflect.ts} +13 -12
- package/src/tools/control-room/ui/dist/assets/{index-DyhnNmdi.js → index-D_o9Usd_.js} +1 -1
- package/src/tools/control-room/ui/dist/index.html +1 -1
- package/src/tools/control-room/ui/screens/settings.tsx +2 -2
- package/src/tools/self-model.ts +1 -0
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ pal cli status # check your setup
|
|
|
77
77
|
| `pal cli init` | Scaffold PAL home directory and install hooks |
|
|
78
78
|
| `pal cli install` | Register hooks/skills for targets |
|
|
79
79
|
| `pal cli uninstall` | Remove hooks/skills for targets |
|
|
80
|
-
| `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this
|
|
80
|
+
| `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this on its own: when a session closes PAL checks for a release (at most hourly) and installs it, so opening PAL is never slowed. A failed update waits a day before retrying. The switch lives in the control room under Settings → Updates. On a repo install an update waits while the clone has uncommitted changes |
|
|
81
81
|
| `pal cli export` | Export user state (telos, memory) to a zip |
|
|
82
82
|
| `pal cli import` | Import user state from a zip |
|
|
83
83
|
| `pal cli status` | Show current PAL configuration |
|
|
@@ -156,7 +156,7 @@ PAL routes inference through the host agent's subscription CLI by default. API k
|
|
|
156
156
|
Enable verbose hook logging with:
|
|
157
157
|
|
|
158
158
|
```
|
|
159
|
-
pal cli debug on # enable → logs to
|
|
159
|
+
pal cli debug on # enable → logs to debug/debug.log
|
|
160
160
|
pal cli debug off # disable
|
|
161
161
|
pal cli debug # show current status and log path
|
|
162
162
|
```
|
|
@@ -165,6 +165,7 @@ pal cli debug # show current status and log path
|
|
|
165
165
|
|----------|-------------|
|
|
166
166
|
| `PAL_INFERENCE_DISABLED` | Set to `1` to disable all inference (used by the test suite to prevent real CLI spawns) |
|
|
167
167
|
| `PAL_NOTIFICATIONS_DISABLED` | Set to `1` to suppress desktop notifications (used by the test suite) |
|
|
168
|
+
| `PAL_TEST_SANDBOX` | Set by the test suite. Without a `PAL_HOME`, state goes to a temp directory instead of `~/.pal`, and installers refuse to touch real agent directories |
|
|
168
169
|
|
|
169
170
|
---
|
|
170
171
|
|
|
@@ -57,7 +57,6 @@
|
|
|
57
57
|
"wisdom": { "type": "boolean", "description": "Crystallized wisdom principles." },
|
|
58
58
|
"opinions": { "type": "boolean", "description": "Tracked user opinions." },
|
|
59
59
|
"relationship": { "type": "boolean", "description": "Recent relationship notes." },
|
|
60
|
-
"learningDigest": { "type": "boolean", "description": "Lessons-from-failures digest." },
|
|
61
60
|
"failurePatterns": { "type": "boolean", "description": "Recurring failure patterns." },
|
|
62
61
|
"projectHistory": { "type": "boolean", "description": "Per-project session history." },
|
|
63
62
|
"projects": { "type": "boolean", "description": "Active projects list." },
|
|
@@ -19,10 +19,10 @@ When `/pal-reflect` is invoked (by you in response to a nudge, or by the user di
|
|
|
19
19
|
## 1. Run the reflect tool
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
|
|
22
|
+
pal cli relationship-reflect
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
If the user passed `--dry-run`, append it: `
|
|
25
|
+
If the user passed `--dry-run`, append it: `pal cli relationship-reflect --dry-run`
|
|
26
26
|
|
|
27
27
|
## 2. Parse and present the output
|
|
28
28
|
|
|
@@ -383,7 +383,7 @@ Relationship notes (O/B types)
|
|
|
383
383
|
- Content is global/user-level — safe to pre-compile (not project-scoped)
|
|
384
384
|
|
|
385
385
|
**Tier 3 — Dynamic** (injected fresh each session by LoadContext hook):
|
|
386
|
-
- Handoff notes, session intelligence,
|
|
386
|
+
- Handoff notes, session intelligence, relationship notes, project history
|
|
387
387
|
- Changes per-session or is project-scoped — can't be pre-compiled
|
|
388
388
|
- Injected as `<system-reminder>` block via stdout
|
|
389
389
|
- Each section independently toggleable in `pal-settings.json → dynamicContext`
|
|
@@ -402,12 +402,12 @@ LoadContext.ts
|
|
|
402
402
|
└─► Build system-reminder (dynamic sections only):
|
|
403
403
|
1. loadAtStartup files (user-configured)
|
|
404
404
|
2. Handoff note (in-progress work from last session)
|
|
405
|
-
3.
|
|
406
|
-
4.
|
|
405
|
+
3. Due nudges (algorithm review, relationship reflect, learning analysis)
|
|
406
|
+
4. Session intelligence (rating trend, algorithm performance)
|
|
407
407
|
5. Recent interaction notes (last 2 days)
|
|
408
408
|
6. Active projects
|
|
409
409
|
7. Project session history (this project)
|
|
410
|
-
8.
|
|
410
|
+
8. Other recent learnings (other projects, titles only)
|
|
411
411
|
```
|
|
412
412
|
|
|
413
413
|
### On-Demand Context
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.0",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"tool:synthesize": "bun run src/tools/agent/synthesize.ts",
|
|
65
65
|
"tool:thread": "bun run src/tools/agent/thread.ts",
|
|
66
66
|
"tool:wisdom-frame": "bun run src/tools/agent/wisdom-frame.ts",
|
|
67
|
-
"tool:reflect": "bun run src/tools/relationship-reflect.ts",
|
|
67
|
+
"tool:reflect": "bun run src/tools/agent/relationship-reflect.ts",
|
|
68
68
|
"eval:sentiment": "bun eval/run.ts sentiment",
|
|
69
69
|
"eval:failure-principle": "bun eval/run.ts failure-principle",
|
|
70
70
|
"eval:goal-links": "bun eval/run.ts goal-links",
|
package/src/cli/builtin-tools.ts
CHANGED
|
@@ -13,6 +13,7 @@ const BUILTIN_TOOLS = {
|
|
|
13
13
|
"handoff-note": () => import("../tools/agent/handoff-note"),
|
|
14
14
|
project: () => import("../tools/agent/project"),
|
|
15
15
|
"relationship-note": () => import("../tools/agent/relationship-note"),
|
|
16
|
+
"relationship-reflect": () => import("../tools/agent/relationship-reflect"),
|
|
16
17
|
synthesize: () => import("../tools/agent/synthesize"),
|
|
17
18
|
thread: () => import("../tools/agent/thread"),
|
|
18
19
|
"wisdom-frame": () => import("../tools/agent/wisdom-frame"),
|
package/src/cli/index.ts
CHANGED
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
summarize,
|
|
48
48
|
} from "../hooks/lib/import-merge";
|
|
49
49
|
import { inference, previewInferenceRoute } from "../hooks/lib/inference";
|
|
50
|
-
import {
|
|
50
|
+
import { logDebug, recentHookErrors } from "../hooks/lib/log";
|
|
51
51
|
import { ensureRegistered, writeRegistryEntry } from "../hooks/lib/machine";
|
|
52
52
|
import { palHome, palPkg, paths, platform, toPath } from "../hooks/lib/paths";
|
|
53
53
|
import { auditBindings, describeBindingIssue } from "../hooks/lib/projects";
|
|
@@ -462,11 +462,6 @@ function resolveTargets(args: string[], health?: DoctorResult): Targets {
|
|
|
462
462
|
|
|
463
463
|
// ── Hook health ──
|
|
464
464
|
|
|
465
|
-
interface HookHealth {
|
|
466
|
-
totalErrors: number;
|
|
467
|
-
lastError: string | null;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
465
|
function checkClaudeHooksRegistered(): boolean {
|
|
471
466
|
const settingsPath = resolve(platform.claudeDir(), "settings.json");
|
|
472
467
|
if (!existsSync(settingsPath)) return false;
|
|
@@ -645,7 +640,7 @@ async function probeInference(): Promise<void> {
|
|
|
645
640
|
);
|
|
646
641
|
} else {
|
|
647
642
|
console.log(
|
|
648
|
-
` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}failed — see ~/.pal/
|
|
643
|
+
` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}failed — see ~/.pal/debug/debug.log${reset}`
|
|
649
644
|
);
|
|
650
645
|
}
|
|
651
646
|
}
|
|
@@ -733,46 +728,6 @@ function rtkInstallHint(): string {
|
|
|
733
728
|
return "`curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh`";
|
|
734
729
|
}
|
|
735
730
|
|
|
736
|
-
function checkHookHealth(home: string): HookHealth {
|
|
737
|
-
const stateDir = resolve(home, "memory", "state");
|
|
738
|
-
// Read current + all rotated logs (`.1`..`.5`) + legacy `.prev` so a recent
|
|
739
|
-
// rotation doesn't make the 24h window appear empty.
|
|
740
|
-
const candidates = [
|
|
741
|
-
resolve(stateDir, "debug.log"),
|
|
742
|
-
resolve(stateDir, "debug.log.prev"),
|
|
743
|
-
...Array.from({ length: DEBUG_LOG_MAX_ROTATED }, (_, i) =>
|
|
744
|
-
resolve(stateDir, `debug.log.${i + 1}`)
|
|
745
|
-
),
|
|
746
|
-
];
|
|
747
|
-
|
|
748
|
-
try {
|
|
749
|
-
let content = "";
|
|
750
|
-
for (const path of candidates) {
|
|
751
|
-
if (existsSync(path)) content += `${readFileSync(path, "utf-8")}\n`;
|
|
752
|
-
}
|
|
753
|
-
if (!content) return { totalErrors: 0, lastError: null };
|
|
754
|
-
|
|
755
|
-
const lines = content.split("\n").filter((l) => l.includes("] ERROR "));
|
|
756
|
-
|
|
757
|
-
// Filter to last 24h
|
|
758
|
-
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
759
|
-
const recentErrors = lines.filter((line) => {
|
|
760
|
-
const match = new RegExp(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/).exec(line);
|
|
761
|
-
if (!match) return false;
|
|
762
|
-
return new Date(match[1]) > cutoff;
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
const lastError =
|
|
766
|
-
recentErrors.length > 0
|
|
767
|
-
? (recentErrors.at(-1) ?? "").replace(/^\[.*?\] ERROR /, "").slice(0, 120)
|
|
768
|
-
: null;
|
|
769
|
-
|
|
770
|
-
return { totalErrors: recentErrors.length, lastError };
|
|
771
|
-
} catch {
|
|
772
|
-
return { totalErrors: 0, lastError: null };
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
731
|
// ── Doctor ──
|
|
777
732
|
|
|
778
733
|
interface DoctorResult {
|
|
@@ -1131,7 +1086,7 @@ function doctor(silent = false): DoctorResult {
|
|
|
1131
1086
|
: warn("PAL_PERPLEXITY_API_KEY — not set (optional, for Perplexity researcher)");
|
|
1132
1087
|
|
|
1133
1088
|
// Hook health from debug.log
|
|
1134
|
-
const hookHealth =
|
|
1089
|
+
const hookHealth = recentHookErrors();
|
|
1135
1090
|
if (hookHealth.totalErrors === 0) {
|
|
1136
1091
|
ok("Hooks: no recent errors");
|
|
1137
1092
|
} else {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Unattended updates — one-time opt-in prompt.
|
|
3
3
|
*
|
|
4
4
|
* Asked once during `pal install`, the same seam git attribution uses, so a new
|
|
5
5
|
* user sees it at init and an existing one on their next update. The non-TTY
|
|
@@ -14,11 +14,11 @@ import { raw as readSettings, write as writeSettings } from "../hooks/lib/settin
|
|
|
14
14
|
|
|
15
15
|
/** Only a git clone can be mid-change; a global package install has no such state. */
|
|
16
16
|
function whatItDoes(): string {
|
|
17
|
-
const
|
|
18
|
-
"
|
|
17
|
+
const onClose =
|
|
18
|
+
"When you close a session, PAL checks for a new release (at most hourly) and installs it in the background.\nOpening PAL is never slowed down, and the statusline says when to restart.";
|
|
19
19
|
return isRepoMode()
|
|
20
|
-
? `${
|
|
21
|
-
:
|
|
20
|
+
? `${onClose}\nIt waits while this clone has uncommitted changes.`
|
|
21
|
+
: onClose;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export async function promptAutoUpdate(): Promise<void> {
|
|
@@ -31,7 +31,7 @@ export async function promptAutoUpdate(): Promise<void> {
|
|
|
31
31
|
clack.note(whatItDoes(), "Keep PAL up to date on its own?");
|
|
32
32
|
|
|
33
33
|
const enabled = await clack.confirm({
|
|
34
|
-
message: "Turn on
|
|
34
|
+
message: "Turn on automatic updates?",
|
|
35
35
|
initialValue: false,
|
|
36
36
|
});
|
|
37
37
|
if (clack.isCancel(enabled)) {
|
|
@@ -41,6 +41,6 @@ export async function promptAutoUpdate(): Promise<void> {
|
|
|
41
41
|
|
|
42
42
|
settings.autoUpdate = { enabled: enabled === true, decided: true };
|
|
43
43
|
writeSettings(settings);
|
|
44
|
-
const state = enabled ? "
|
|
44
|
+
const state = enabled ? "Automatic updates on" : "Automatic updates off";
|
|
45
45
|
clack.outro(`${state} ✓ · change later: control room → Settings → Updates`);
|
|
46
46
|
}
|
package/src/hooks/AutoUpdate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Spawned, never awaited — runs the
|
|
2
|
+
* Spawned, never awaited — runs the unattended self-update in a process of its own.
|
|
3
3
|
*
|
|
4
4
|
* LoadContext decides whether today's update is due; the control room's button
|
|
5
5
|
* starts this directly. Either way the work happens here so nothing waits on a
|
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -18,11 +18,13 @@ import { logContextSnapshot, logDebug, logError } from "./lib/log";
|
|
|
18
18
|
import { platform } from "./lib/paths";
|
|
19
19
|
import {
|
|
20
20
|
contextEnvelope,
|
|
21
|
+
contextForSource,
|
|
21
22
|
copilotInstructions,
|
|
22
23
|
isSubagentSession,
|
|
23
24
|
needsAgentsMd,
|
|
24
25
|
} from "./lib/session-context";
|
|
25
26
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
27
|
+
import { readStdinJSON } from "./lib/stdin";
|
|
26
28
|
|
|
27
29
|
// Recursion guard — when this process is a PAL-spawned inference subprocess,
|
|
28
30
|
// skip all context loading so we don't trigger another inference call.
|
|
@@ -47,11 +49,21 @@ try {
|
|
|
47
49
|
|
|
48
50
|
try {
|
|
49
51
|
const active = getActiveAgent();
|
|
52
|
+
const input = await readStdinJSON<{ source?: string }>();
|
|
53
|
+
const wanted = contextForSource(active, input?.source);
|
|
54
|
+
if (wanted === "none") {
|
|
55
|
+
logDebug("LoadContext", `source=${input?.source}, context already present`);
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
// The reminder is built for one of three targets; every other agent reads the
|
|
51
60
|
// same shape Claude Code does.
|
|
52
61
|
const target: AgentTarget =
|
|
53
62
|
active === "copilot" || active === "cursor" ? active : "claude";
|
|
54
|
-
const reminder = buildSystemReminder({
|
|
63
|
+
const reminder = buildSystemReminder({
|
|
64
|
+
agent: target,
|
|
65
|
+
withoutHandoff: wanted === "without-handoff",
|
|
66
|
+
});
|
|
55
67
|
if (!reminder) process.exit(0);
|
|
56
68
|
logContextSnapshot(reminder);
|
|
57
69
|
|
|
@@ -17,6 +17,7 @@ import { resolve } from "node:path";
|
|
|
17
17
|
import { persistLastExchange } from "./handlers/persist-last-exchange";
|
|
18
18
|
import { logDebug, logError } from "./lib/log";
|
|
19
19
|
import { paths } from "./lib/paths";
|
|
20
|
+
import { sessionDir } from "./lib/session-dir";
|
|
20
21
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
21
22
|
import { readStdinJSON } from "./lib/stdin";
|
|
22
23
|
import { readTranscriptFile } from "./lib/transcript";
|
|
@@ -27,7 +28,6 @@ if (isPalSpawnedInference()) process.exit(0);
|
|
|
27
28
|
interface PreCompactInput {
|
|
28
29
|
session_id?: string;
|
|
29
30
|
transcript_path?: string;
|
|
30
|
-
cwd?: string;
|
|
31
31
|
hook_event_name?: string;
|
|
32
32
|
trigger?: "manual" | "auto";
|
|
33
33
|
custom_instructions?: string;
|
|
@@ -47,7 +47,7 @@ const main = async () => {
|
|
|
47
47
|
process.exit(0);
|
|
48
48
|
}
|
|
49
49
|
const sessionId = input.session_id ?? "unknown";
|
|
50
|
-
const cwd =
|
|
50
|
+
const cwd = sessionDir();
|
|
51
51
|
|
|
52
52
|
// Stop fires after every response and is authoritative. Skip if it already
|
|
53
53
|
// wrote latest.json for this session — PreCompact is a safety net only.
|
|
@@ -4,22 +4,22 @@
|
|
|
4
4
|
* Writes two outputs:
|
|
5
5
|
* 1. last-exchange/{sessionId}.json + last-exchange/latest.json
|
|
6
6
|
* → read by CompactRecover to re-inject after compaction
|
|
7
|
-
* 2. last-handoff.json keyed by
|
|
8
|
-
* → read by
|
|
7
|
+
* 2. last-handoff.json keyed by the session's start folder
|
|
8
|
+
* → read by loadHandoffContext() to surface "Pick Up Where You Left Off"
|
|
9
9
|
*
|
|
10
|
-
* last-exchange is always overwritten — Stop is its source of truth.
|
|
11
|
-
*
|
|
12
|
-
* source:"deliberate")
|
|
13
|
-
*
|
|
14
|
-
* clobber the curated handoff the moment the session stopped (ISC-39).
|
|
10
|
+
* last-exchange is always overwritten — Stop is its source of truth. In
|
|
11
|
+
* last-handoff only the exchange fields are: a deliberate LEARN-phase note
|
|
12
|
+
* (source:"deliberate") and the summary session intelligence wrote for this
|
|
13
|
+
* session both stay beside it (ISC-39).
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
16
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
17
|
import { resolve } from "node:path";
|
|
18
|
+
import { trimAtWord } from "../lib/handoff-context";
|
|
19
19
|
import { logDebug, logError } from "../lib/log";
|
|
20
20
|
import { ensureDir, paths } from "../lib/paths";
|
|
21
|
+
import { sessionDir } from "../lib/session-dir";
|
|
21
22
|
import { extractContent, extractLastAssistant, extractLastUser } from "../lib/transcript";
|
|
22
|
-
import { detectStatus } from "../lib/work-tracking";
|
|
23
23
|
|
|
24
24
|
type ParsedMessage = { role: string; content: unknown };
|
|
25
25
|
|
|
@@ -35,10 +35,128 @@ function isProtectedHandoff(entry: unknown): boolean {
|
|
|
35
35
|
return Date.now() - new Date(e.timestamp).getTime() <= HANDOFF_STALE_MS;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
interface AutoHandoff {
|
|
39
|
+
title: string;
|
|
40
|
+
status: string;
|
|
41
|
+
handoff: string;
|
|
42
|
+
waitingOn?: string;
|
|
43
|
+
sessionId?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface LastExchange {
|
|
47
|
+
sessionId: string;
|
|
48
|
+
lastUser: string;
|
|
49
|
+
lastAssistant: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type StoredHandoff = Partial<AutoHandoff & LastExchange> & {
|
|
53
|
+
timestamp?: string;
|
|
54
|
+
source?: string;
|
|
55
|
+
artifacts?: string[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const LAST_USER_MAX_CHARS = 200;
|
|
59
|
+
const LAST_ASSISTANT_MAX_CHARS = 300;
|
|
60
|
+
|
|
61
|
+
function handoffPath(): string {
|
|
62
|
+
return resolve(ensureDir(paths.state()), "last-handoff.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readHandoffs(): Record<string, StoredHandoff> {
|
|
66
|
+
try {
|
|
67
|
+
const p = handoffPath();
|
|
68
|
+
return existsSync(p) ? JSON.parse(readFileSync(p, "utf-8")) : {};
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeHandoffs(handoffs: Record<string, StoredHandoff>): void {
|
|
75
|
+
writeFileSync(handoffPath(), JSON.stringify(handoffs, null, 2), "utf-8");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function structuredHandoff(parts: {
|
|
79
|
+
done: string;
|
|
80
|
+
next: string;
|
|
81
|
+
waitingOn: string;
|
|
82
|
+
}): string {
|
|
83
|
+
return [
|
|
84
|
+
parts.done && `Done: ${parts.done}`,
|
|
85
|
+
parts.next && `Next: ${parts.next}`,
|
|
86
|
+
parts.waitingOn && `Waiting on you: ${parts.waitingOn}`,
|
|
87
|
+
]
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sameSession(entry: StoredHandoff | undefined, sessionId?: string): boolean {
|
|
93
|
+
return sessionId !== undefined && entry?.sessionId === sessionId;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Record this folder's summary unless a fresh deliberate note already owns it (ISC-39). */
|
|
97
|
+
export function writeAutoHandoff(cwd: string, entry: AutoHandoff): void {
|
|
98
|
+
const handoffs = readHandoffs();
|
|
99
|
+
const existing = handoffs[cwd];
|
|
100
|
+
if (isProtectedHandoff(existing)) return;
|
|
101
|
+
const exchange = sameSession(existing, entry.sessionId)
|
|
102
|
+
? { lastUser: existing?.lastUser, lastAssistant: existing?.lastAssistant }
|
|
103
|
+
: {};
|
|
104
|
+
handoffs[cwd] = {
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
...entry,
|
|
107
|
+
...exchange,
|
|
108
|
+
artifacts: [],
|
|
109
|
+
source: "auto",
|
|
110
|
+
};
|
|
111
|
+
writeHandoffs(handoffs);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function closingParagraph(text: string): string {
|
|
115
|
+
const paragraphs = text
|
|
116
|
+
.split(/\n\s*\n/)
|
|
117
|
+
.map((p) => p.trim())
|
|
118
|
+
.filter((p) => p.length >= 15);
|
|
119
|
+
return paragraphs.at(-1) ?? text;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The raw exchange is written on every stop; the summary only every few
|
|
124
|
+
* messages. So within one session the exchange is refreshed beside the
|
|
125
|
+
* summary rather than over it, and a new session starts a clean entry.
|
|
126
|
+
*/
|
|
127
|
+
function recordLastExchange(cwd: string, exchange: LastExchange): void {
|
|
128
|
+
const handoffs = readHandoffs();
|
|
129
|
+
const existing = handoffs[cwd];
|
|
130
|
+
const trimmed = {
|
|
131
|
+
sessionId: exchange.sessionId,
|
|
132
|
+
lastUser: trimAtWord(exchange.lastUser, LAST_USER_MAX_CHARS),
|
|
133
|
+
lastAssistant: trimAtWord(
|
|
134
|
+
closingParagraph(exchange.lastAssistant),
|
|
135
|
+
LAST_ASSISTANT_MAX_CHARS
|
|
136
|
+
),
|
|
137
|
+
};
|
|
138
|
+
if (isProtectedHandoff(existing)) {
|
|
139
|
+
handoffs[cwd] = { ...existing, ...trimmed };
|
|
140
|
+
} else if (sameSession(existing, exchange.sessionId)) {
|
|
141
|
+
handoffs[cwd] = { ...existing, ...trimmed, timestamp: new Date().toISOString() };
|
|
142
|
+
} else {
|
|
143
|
+
handoffs[cwd] = {
|
|
144
|
+
timestamp: new Date().toISOString(),
|
|
145
|
+
title: trimAtWord(exchange.lastUser, 80) || "Session",
|
|
146
|
+
status: "in-progress",
|
|
147
|
+
handoff: "",
|
|
148
|
+
artifacts: [],
|
|
149
|
+
source: "auto",
|
|
150
|
+
...trimmed,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
writeHandoffs(handoffs);
|
|
154
|
+
}
|
|
155
|
+
|
|
38
156
|
export function persistLastExchange(
|
|
39
157
|
messages: ParsedMessage[],
|
|
40
158
|
sessionId: string,
|
|
41
|
-
cwd: string =
|
|
159
|
+
cwd: string = sessionDir()
|
|
42
160
|
): void {
|
|
43
161
|
try {
|
|
44
162
|
const lastUser = extractContent(extractLastUser(messages));
|
|
@@ -59,30 +177,7 @@ export function persistLastExchange(
|
|
|
59
177
|
writeFileSync(resolve(stateDir, `${sessionId}.json`), json, "utf-8");
|
|
60
178
|
writeFileSync(resolve(stateDir, "latest.json"), json, "utf-8");
|
|
61
179
|
|
|
62
|
-
|
|
63
|
-
// fresh deliberate note already owns this cwd (ISC-39).
|
|
64
|
-
const handoffPath = resolve(paths.state(), "last-handoff.json");
|
|
65
|
-
const existing: Record<string, unknown> = existsSync(handoffPath)
|
|
66
|
-
? JSON.parse(readFileSync(handoffPath, "utf-8"))
|
|
67
|
-
: {};
|
|
68
|
-
if (!isProtectedHandoff(existing[cwd])) {
|
|
69
|
-
const title = (lastUser.slice(0, 80).replace(/\n/g, " ") || "Session").trim();
|
|
70
|
-
const handoff = [
|
|
71
|
-
lastUser ? `Last user message:\n${lastUser.slice(0, 500)}` : "",
|
|
72
|
-
lastAssistant ? `\nLast assistant response:\n${lastAssistant.slice(0, 500)}` : "",
|
|
73
|
-
]
|
|
74
|
-
.filter(Boolean)
|
|
75
|
-
.join("");
|
|
76
|
-
existing[cwd] = {
|
|
77
|
-
timestamp: new Date().toISOString(),
|
|
78
|
-
title,
|
|
79
|
-
status: detectStatus(lastAssistant),
|
|
80
|
-
handoff,
|
|
81
|
-
artifacts: [],
|
|
82
|
-
source: "auto",
|
|
83
|
-
};
|
|
84
|
-
writeFileSync(handoffPath, JSON.stringify(existing, null, 2), "utf-8");
|
|
85
|
-
}
|
|
180
|
+
recordLastExchange(cwd, { sessionId, lastUser, lastAssistant });
|
|
86
181
|
|
|
87
182
|
logDebug(
|
|
88
183
|
"persist-last-exchange",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stop handler: unified session intelligence capture.
|
|
3
3
|
*
|
|
4
|
-
* Produces: title, summary, insights via Haiku.
|
|
5
|
-
* Writes: session learning file, project history.
|
|
4
|
+
* Produces: title, summary, insights, status and a done/next/waiting handoff via Haiku.
|
|
5
|
+
* Writes: session learning file, project history, auto handoff.
|
|
6
6
|
*
|
|
7
7
|
* Relationship notes → written in ALGORITHM LEARN phase via relationship-note.ts
|
|
8
8
|
* Handoff notes → written in ALGORITHM LEARN phase via handoff-note.ts
|
|
@@ -23,6 +23,7 @@ import { canInfer, inference } from "../lib/inference";
|
|
|
23
23
|
import { categorizeLearning } from "../lib/learning-category";
|
|
24
24
|
import { logDebug, logError } from "../lib/log";
|
|
25
25
|
import { ensureDir, paths } from "../lib/paths";
|
|
26
|
+
import { sessionDir } from "../lib/session-dir";
|
|
26
27
|
import { fileTimestamp, monthPath } from "../lib/time";
|
|
27
28
|
import { logTokenUsage } from "../lib/token-usage";
|
|
28
29
|
import {
|
|
@@ -31,7 +32,8 @@ import {
|
|
|
31
32
|
extractLastUser,
|
|
32
33
|
parseMessages,
|
|
33
34
|
} from "../lib/transcript";
|
|
34
|
-
import { appendProjectHistory
|
|
35
|
+
import { appendProjectHistory } from "../lib/work-tracking";
|
|
36
|
+
import { structuredHandoff, writeAutoHandoff } from "./persist-last-exchange";
|
|
35
37
|
|
|
36
38
|
// ── JSON schema for merged Haiku call ──
|
|
37
39
|
|
|
@@ -50,20 +52,51 @@ const INTELLIGENCE_SCHEMA = {
|
|
|
50
52
|
description:
|
|
51
53
|
"What worked, what was surprising, what to do differently, 2-3 bullet points",
|
|
52
54
|
},
|
|
53
|
-
|
|
55
|
+
status: {
|
|
54
56
|
type: "string" as const,
|
|
57
|
+
enum: ["in-progress", "completed"],
|
|
55
58
|
description:
|
|
56
|
-
"
|
|
59
|
+
"in-progress if work remains or the AI's last message awaits the user's answer; completed only if nothing is left",
|
|
60
|
+
},
|
|
61
|
+
done: {
|
|
62
|
+
type: "string" as const,
|
|
63
|
+
description: "What the latest piece of work finished, one sentence",
|
|
64
|
+
},
|
|
65
|
+
next: {
|
|
66
|
+
type: "string" as const,
|
|
67
|
+
description: "The concrete next step, one sentence, empty if none",
|
|
68
|
+
},
|
|
69
|
+
waitingOn: {
|
|
70
|
+
type: "string" as const,
|
|
71
|
+
description:
|
|
72
|
+
"What the AI needs from the user before it can continue, empty if nothing",
|
|
57
73
|
},
|
|
58
74
|
},
|
|
59
|
-
required: [
|
|
75
|
+
required: [
|
|
76
|
+
"title",
|
|
77
|
+
"summary",
|
|
78
|
+
"insights",
|
|
79
|
+
"status",
|
|
80
|
+
"done",
|
|
81
|
+
"next",
|
|
82
|
+
"waitingOn",
|
|
83
|
+
] as const,
|
|
60
84
|
};
|
|
61
85
|
|
|
62
86
|
interface IntelligenceOutput {
|
|
63
87
|
title: string;
|
|
64
88
|
summary: string;
|
|
65
89
|
insights: string;
|
|
66
|
-
|
|
90
|
+
status: "in-progress" | "completed";
|
|
91
|
+
done: string;
|
|
92
|
+
next: string;
|
|
93
|
+
waitingOn: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A reply states its verdict first and its question to the user last. */
|
|
97
|
+
export function openingAndClosing(text: string, each: number): string {
|
|
98
|
+
if (text.length <= each * 2) return text;
|
|
99
|
+
return `${text.slice(0, each)} … ${text.slice(-each)}`;
|
|
67
100
|
}
|
|
68
101
|
|
|
69
102
|
// ── Main handler ──
|
|
@@ -95,10 +128,9 @@ export async function captureSessionIntelligence(
|
|
|
95
128
|
const lastAssistant = extractLastAssistant(messages);
|
|
96
129
|
const lastAssistantText = extractContent(lastAssistant);
|
|
97
130
|
const lastUser = extractLastUser(messages);
|
|
98
|
-
const status = detectStatus(lastAssistantText);
|
|
99
131
|
|
|
100
132
|
const userWindow = userMessages.slice(-10).map((t) => t.slice(0, 200));
|
|
101
|
-
const assistantWindow = lastAssistantText
|
|
133
|
+
const assistantWindow = openingAndClosing(lastAssistantText, 300);
|
|
102
134
|
|
|
103
135
|
if (userWindow.length < 3) return;
|
|
104
136
|
|
|
@@ -110,17 +142,15 @@ export async function captureSessionIntelligence(
|
|
|
110
142
|
const result = await inference({
|
|
111
143
|
system: [
|
|
112
144
|
"You analyze a session between a human user and an AI assistant. Sessions may involve coding, research, writing, planning, analysis, or any other task.",
|
|
113
|
-
`Session status: ${status}.`,
|
|
114
145
|
"Produce ALL of the following:",
|
|
115
146
|
"1. title: short title (5-10 words) describing what was accomplished",
|
|
116
147
|
"2. summary: what the AI did for the user (2-4 sentences, AI perspective using 'we')",
|
|
117
148
|
"3. insights: what worked, what was surprising, what to do differently (2-3 points, no markdown)",
|
|
118
|
-
status
|
|
119
|
-
|
|
120
|
-
: "4. handoff: empty string (session completed)",
|
|
149
|
+
"4. status: in-progress if work remains or the last AI response ends with a question or offer awaiting the user; completed only if nothing is left",
|
|
150
|
+
"5. done, next, waitingOn: a handoff for whoever picks this up next. Describe the LATEST thread of work, the one the last messages are about, not earlier topics of the session. One sentence each; next and waitingOn may be empty.",
|
|
121
151
|
].join("\n"),
|
|
122
152
|
user: `User messages:\n${numberedMessages}\n\nLast AI response:\n${assistantWindow}`,
|
|
123
|
-
maxTokens:
|
|
153
|
+
maxTokens: 450,
|
|
124
154
|
timeout: 90000,
|
|
125
155
|
jsonSchema: INTELLIGENCE_SCHEMA,
|
|
126
156
|
caller: "session-intelligence",
|
|
@@ -140,6 +170,15 @@ export async function captureSessionIntelligence(
|
|
|
140
170
|
const title = output?.title || extractContent(lastUser).slice(0, 80) || "session";
|
|
141
171
|
const summary = output?.summary || lastAssistantText.slice(0, 600);
|
|
142
172
|
const insights = output?.insights || "";
|
|
173
|
+
if (output) {
|
|
174
|
+
writeAutoHandoff(sessionDir(), {
|
|
175
|
+
title,
|
|
176
|
+
status: output.status,
|
|
177
|
+
handoff: structuredHandoff(output),
|
|
178
|
+
waitingOn: output.waitingOn,
|
|
179
|
+
sessionId,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
143
182
|
// ── Write session learning file ──
|
|
144
183
|
|
|
145
184
|
const category = categorizeLearning(title, summary);
|
|
@@ -35,11 +35,13 @@ export function writeReviewMark(ts: string): void {
|
|
|
35
35
|
writeFileSync(markFile(), `${JSON.stringify({ lastReviewedTs: ts }, null, 2)}\n`);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
function hasAlgorithmUpdateSkill(root: string): boolean {
|
|
39
|
+
return existsSync(resolve(root, ".agents", "skills", "algorithm-update", "SKILL.md"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when PAL runs from its checkout, or a session is working inside one. */
|
|
43
|
+
export function isMaintainerEnv(cwd: string = process.cwd()): boolean {
|
|
44
|
+
return hasAlgorithmUpdateSkill(palPkg()) || hasAlgorithmUpdateSkill(cwd);
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/** Count reflections newer than the last review mark (all of them, if never reviewed). */
|
|
@@ -60,8 +62,11 @@ export interface ReviewNudge {
|
|
|
60
62
|
* Nudge data when BOTH thresholds are met (≥25 new AND ≥7 days) in a maintainer
|
|
61
63
|
* env, else null. `now` is injected for testability.
|
|
62
64
|
*/
|
|
63
|
-
export function algorithmReviewNudge(
|
|
64
|
-
|
|
65
|
+
export function algorithmReviewNudge(
|
|
66
|
+
now: Date,
|
|
67
|
+
cwd: string = process.cwd()
|
|
68
|
+
): ReviewNudge | null {
|
|
69
|
+
if (!isMaintainerEnv(cwd)) return null;
|
|
65
70
|
const mark = readReviewMark();
|
|
66
71
|
const count = countUnreviewed(mark);
|
|
67
72
|
if (count < NUDGE_MIN_COUNT) return null;
|