Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e095b5d
Add Discord archive config and session manager snapshot APIs
drpedapati Feb 27, 2026
eea4b13
Add Discord archive manager with trim and lexical recall
drpedapati Feb 27, 2026
1a21de4
Add archive discord CLI commands for list run recall
drpedapati Feb 27, 2026
93ebf51
Trigger Discord auto-archive in agent loop with integration test
drpedapati Feb 27, 2026
2ffcaf3
Fix Discord recall when session-key filter is omitted
drpedapati Feb 27, 2026
af96a1d
Phase 2: inject bounded Discord archive recall in agent loop
drpedapati Feb 27, 2026
be14eaf
Fix Discord routing stalls on cloud-backed workspaces
drpedapati Feb 27, 2026
3d88f1d
Prevent session preload stalls from blocking routed messages
drpedapati Feb 27, 2026
d9c04d4
Fail-open state bootstrap on slow cloud filesystems
drpedapati Feb 27, 2026
582d4e7
Make state persistence async to avoid routing stalls
drpedapati Feb 27, 2026
8dd4d62
Improve message-tool completion logging and fallback behavior
drpedapati Feb 27, 2026
d261de4
Add step-level turn logs and detailed session save diagnostics
drpedapati Feb 27, 2026
34e1b0e
Add archive step diagnostics and fail-open archive checks
drpedapati Feb 27, 2026
d27dfd2
Cache hook policy in handler to avoid per-turn workspace reloads
drpedapati Feb 27, 2026
35a4c73
Make hook audit writes async and non-blocking
drpedapati Feb 27, 2026
73822e1
Bound filesystem read/list tools with fail-open timeouts
drpedapati Feb 27, 2026
b8fb5de
Add staged exec diagnostics for flaky filesystem stalls
drpedapati Feb 27, 2026
a23cc28
tui: show doctor report on non-zero json exit
drpedapati Feb 27, 2026
4c333d1
doctor: detect bundled skills in sciclaw-dev installs
drpedapati Feb 27, 2026
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
272 changes: 272 additions & 0 deletions cmd/picoclaw/archive_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
package main

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"

"github.com/sipeed/picoclaw/pkg/archive/discordarchive"
"github.com/sipeed/picoclaw/pkg/session"
)

type archiveRunOptions struct {
SessionKey string
All bool
OverLimit bool
DryRun bool
}

type archiveRecallOptions struct {
SessionKey string
TopK int
MaxChars int
JSON bool
Query string
}

func archiveCmd() {
if len(os.Args) < 3 {
archiveHelp()
return
}
switch strings.ToLower(strings.TrimSpace(os.Args[2])) {
case "discord":
archiveDiscordCmd(os.Args[3:])
case "help", "--help", "-h":
archiveHelp()
default:
fmt.Printf("Unknown archive command: %s\n", os.Args[2])
archiveHelp()
}
}

func archiveDiscordCmd(args []string) {
if len(args) == 0 {
archiveDiscordHelp()
return
}
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
}
workspace := cfg.WorkspacePath()
sm := session.NewSessionManager(filepath.Join(workspace, "sessions"))
manager := discordarchive.NewManager(workspace, sm, cfg.Channels.Discord.Archive)

sub := strings.ToLower(strings.TrimSpace(args[0]))
switch sub {
case "list":
overLimitOnly := false
jsonOut := false
for _, arg := range args[1:] {
switch strings.ToLower(strings.TrimSpace(arg)) {
case "--over-limit":
overLimitOnly = true
case "--json":
jsonOut = true
}
}
stats := manager.ListDiscordSessions(overLimitOnly)
if jsonOut {
out, _ := json.MarshalIndent(stats, "", " ")
fmt.Println(string(out))
return
}
if len(stats) == 0 {
fmt.Println("No Discord sessions found.")
return
}
fmt.Println("Discord sessions:")
for _, stat := range stats {
over := "no"
if stat.OverLimit {
over = "yes"
}
fmt.Printf(" - %s | messages=%d tokens~%d over_limit=%s\n", stat.SessionKey, stat.Messages, stat.Tokens, over)
}
case "run":
opts, err := parseArchiveRunOptions(args[1:])
if err != nil {
fmt.Printf("Error: %v\n", err)
archiveDiscordHelp()
return
}
if opts.SessionKey == "" && !opts.All && !opts.OverLimit {
opts.OverLimit = true
}
if opts.All {
opts.OverLimit = false
}

if opts.SessionKey != "" {
result, err := manager.ArchiveSession(opts.SessionKey, opts.DryRun)
if err != nil {
fmt.Printf("Archive failed: %v\n", err)
return
}
if result == nil {
fmt.Println("No archive action taken.")
return
}
printArchiveResult(*result)
return
}

results, err := manager.ArchiveAll(opts.OverLimit, opts.DryRun)
if err != nil {
fmt.Printf("Archive failed: %v\n", err)
return
}
if len(results) == 0 {
fmt.Println("No sessions archived.")
return
}
for _, result := range results {
printArchiveResult(result)
}
case "recall":
opts, err := parseArchiveRecallOptions(args[1:], cfg.Channels.Discord.Archive.RecallTopK, cfg.Channels.Discord.Archive.RecallMaxChars)
if err != nil {
fmt.Printf("Error: %v\n", err)
archiveDiscordHelp()
return
}
hits := manager.Recall(opts.Query, opts.SessionKey, opts.TopK, opts.MaxChars)
if opts.JSON {
out, _ := json.MarshalIndent(hits, "", " ")
fmt.Println(string(out))
return
}
if len(hits) == 0 {
fmt.Println("No recall hits.")
return
}
for i, hit := range hits {
fmt.Printf("%d) score=%d session=%s file=%s\n", i+1, hit.Score, hit.SessionKey, hit.SourcePath)
fmt.Printf(" %s\n\n", hit.Text)
}
case "index":
// Phase 1 uses lexical recall directly over archive markdown.
fmt.Println("Index step is not required for phase-1 lexical recall (on-demand scan).")
case "help", "--help", "-h":
archiveDiscordHelp()
default:
fmt.Printf("Unknown archive discord command: %s\n", sub)
archiveDiscordHelp()
}
}

func parseArchiveRunOptions(args []string) (archiveRunOptions, error) {
opts := archiveRunOptions{}
for i := 0; i < len(args); i++ {
switch args[i] {
case "--session-key":
if i+1 >= len(args) {
return opts, fmt.Errorf("--session-key requires a value")
}
opts.SessionKey = strings.TrimSpace(args[i+1])
i++
case "--all":
opts.All = true
case "--over-limit":
opts.OverLimit = true
case "--dry-run":
opts.DryRun = true
default:
return opts, fmt.Errorf("unknown option: %s", args[i])
}
}
return opts, nil
}

func parseArchiveRecallOptions(args []string, defaultTopK, defaultMaxChars int) (archiveRecallOptions, error) {
opts := archiveRecallOptions{
TopK: defaultTopK,
MaxChars: defaultMaxChars,
}
queryParts := make([]string, 0, len(args))

for i := 0; i < len(args); i++ {
switch args[i] {
case "--top-k":
if i+1 >= len(args) {
return opts, fmt.Errorf("--top-k requires a value")
}
n, err := strconv.Atoi(args[i+1])
if err != nil || n <= 0 {
return opts, fmt.Errorf("invalid --top-k value: %s", args[i+1])
}
opts.TopK = n
i++
case "--max-chars":
if i+1 >= len(args) {
return opts, fmt.Errorf("--max-chars requires a value")
}
n, err := strconv.Atoi(args[i+1])
if err != nil || n <= 0 {
return opts, fmt.Errorf("invalid --max-chars value: %s", args[i+1])
}
opts.MaxChars = n
i++
case "--session-key":
if i+1 >= len(args) {
return opts, fmt.Errorf("--session-key requires a value")
}
opts.SessionKey = strings.TrimSpace(args[i+1])
i++
case "--json":
opts.JSON = true
default:
queryParts = append(queryParts, args[i])
}
}

opts.Query = strings.TrimSpace(strings.Join(queryParts, " "))
if opts.Query == "" {
return opts, fmt.Errorf("query is required")
}
if opts.TopK <= 0 {
opts.TopK = 6
}
if opts.MaxChars <= 0 {
opts.MaxChars = 3000
}
return opts, nil
}

func printArchiveResult(result discordarchive.ArchiveResult) {
mode := "archived"
if result.DryRun {
mode = "dry-run"
}
fmt.Printf(
"%s: %s | archived=%d kept=%d tokens~%d->%d file=%s\n",
mode,
result.SessionKey,
result.ArchivedMessages,
result.KeptMessages,
result.TokensBefore,
result.TokensAfter,
result.ArchivePath,
)
}

func archiveHelp() {
commandName := invokedCLIName()
fmt.Println("\nArchive commands:")
fmt.Printf(" %s archive discord <list|run|recall|index>\n", commandName)
fmt.Printf(" %s archive discord help\n", commandName)
}

func archiveDiscordHelp() {
commandName := invokedCLIName()
fmt.Println("\nArchive Discord commands:")
fmt.Printf(" %s archive discord list [--over-limit] [--json]\n", commandName)
fmt.Printf(" %s archive discord run [--session-key <key> | --all | --over-limit] [--dry-run]\n", commandName)
fmt.Printf(" %s archive discord recall <query> [--top-k <n>] [--max-chars <n>] [--session-key <key>] [--json]\n", commandName)
fmt.Printf(" %s archive discord index\n", commandName)
}
54 changes: 54 additions & 0 deletions cmd/picoclaw/archive_cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import "testing"

func TestParseArchiveRunOptions(t *testing.T) {
opts, err := parseArchiveRunOptions([]string{"--session-key", "discord:1", "--dry-run"})
if err != nil {
t.Fatalf("parseArchiveRunOptions error: %v", err)
}
if opts.SessionKey != "discord:1" {
t.Fatalf("unexpected session key: %q", opts.SessionKey)
}
if !opts.DryRun {
t.Fatal("expected dry-run=true")
}
}

func TestParseArchiveRunOptionsUnknown(t *testing.T) {
if _, err := parseArchiveRunOptions([]string{"--nope"}); err == nil {
t.Fatal("expected error for unknown option")
}
}

func TestParseArchiveRecallOptions(t *testing.T) {
opts, err := parseArchiveRecallOptions(
[]string{"alpha", "token", "--top-k", "4", "--max-chars", "1200", "--session-key", "discord:1", "--json"},
6,
3000,
)
if err != nil {
t.Fatalf("parseArchiveRecallOptions error: %v", err)
}
if opts.Query != "alpha token" {
t.Fatalf("unexpected query: %q", opts.Query)
}
if opts.TopK != 4 {
t.Fatalf("unexpected top-k: %d", opts.TopK)
}
if opts.MaxChars != 1200 {
t.Fatalf("unexpected max chars: %d", opts.MaxChars)
}
if opts.SessionKey != "discord:1" {
t.Fatalf("unexpected session key: %q", opts.SessionKey)
}
if !opts.JSON {
t.Fatal("expected json=true")
}
}

func TestParseArchiveRecallOptionsMissingQuery(t *testing.T) {
if _, err := parseArchiveRecallOptions([]string{"--top-k", "2"}, 6, 3000); err == nil {
t.Fatal("expected missing query error")
}
}
3 changes: 3 additions & 0 deletions cmd/picoclaw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ func main() {
}
case "backup":
backupCmd()
case "archive":
archiveCmd()
case "version", "--version", "-v":
printVersion()
default:
Expand Down Expand Up @@ -269,6 +271,7 @@ func printHelp() {
fmt.Println(" migrate Migrate from OpenClaw to sciClaw")
fmt.Println(" skills Manage skills (install, list, remove)")
fmt.Println(" backup Backup key sciClaw config/workspace files")
fmt.Println(" archive Manage Discord archive/recall memory")
fmt.Println(" version Show version information")
fmt.Println()
fmt.Println("Agent flags:")
Expand Down
13 changes: 12 additions & 1 deletion config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,18 @@
"discord": {
"enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN",
"allow_from": []
"allow_from": [],
"archive": {
"enabled": true,
"auto_archive": true,
"max_session_tokens": 24000,
"max_session_messages": 120,
"keep_user_pairs": 12,
"min_tail_messages": 4,
"recall_top_k": 6,
"recall_max_chars": 3000,
"recall_min_score": 0.2
}
},
"maixcam": {
"enabled": false,
Expand Down
Loading
Loading