whatsappImportUnknown = regexp.MustCompile(`^\[WhatsApp\] (.*), [A-Za-z]+ (\d{4}-\d{2}-\d{2})$`)
)
-// whatsappImport runs the offline import command. It holds the same token lock
-// as the daemon and probe, even for a plan. The plan reads no credentials for a
-// model and makes no network call. Only a completed rebuild replaces the agent
-// directory, and unfinished subagents prevent either mode.
-func whatsappImport(ctx context.Context, args []string, out io.Writer) (err error) {
- planOnly := len(args) > 0 && args[0] == "-n"
- if planOnly {
- args = args[1:]
+// WhatsAppImportPlan holds the new chat-day batches and the size of the merged
+// log. Its estimate uses the measured cost of 1.3 calls per message, not a
+// promise about a particular export or compaction retry.
+type WhatsAppImportPlan struct {
+ agent string
+ cutoffs map[string]time.Time
+ added []DatedMessage
+ chats []string
+ cutoff time.Time
+ first time.Time
+ last time.Time
+ bytes int
+ messages int
+ skipped int
+ ownerKnown bool
+}
+
+// WhatsAppImportOptions selects the target, exports, and plan-only mode.
+type WhatsAppImportOptions struct {
+ planOnly bool
+ agent string
+ paths []string
+}
+
+// Write prints the plan without chat text or the owner's profile name.
+func (p WhatsAppImportPlan) Write(out io.Writer) error {
+ cutoff := "none (no live WhatsApp batch)"
+ if !p.cutoff.IsZero() {
+ cutoff = p.cutoff.Format(time.RFC3339)
+ }
+
+ dateRange := "none"
+ if !p.first.IsZero() {
+ dateRange = p.first.Format(time.RFC3339) + " to " + p.last.Format(time.RFC3339)
+ }
+
+ owner := "known (mapped to You)"
+ if !p.ownerKnown {
+ owner = "unknown (sender names kept as written)"
}
- if len(args) == 0 {
- return errors.New("usage: onechat import [-n] ZIP [ZIP...]")
+ _, err := fmt.Fprintf(out, "agent: %s\nchats: %d\n", p.agent, len(p.chats))
+ if err != nil {
+ return fmt.Errorf("write import plan: %w", err)
}
- for _, arg := range args {
- if strings.HasPrefix(arg, "-") {
- return errors.New("usage: onechat import [-n] ZIP [ZIP...]")
+ for _, chat := range p.chats {
+ line := " " + chat
+ cutoff, found := p.cutoffs[chat]
+ if found {
+ line += " (cutoff: " + cutoff.Format(time.RFC3339) + ")"
}
+
+ _, err = fmt.Fprintln(out, line)
+ if err != nil {
+ return fmt.Errorf("write import plan: %w", err)
+ }
+ }
+
+ estimate := 1.3 * float64(p.messages)
+ if len(p.added) == 0 {
+ estimate = 0
+ }
+
+ lines := []string{
+ fmt.Sprintf("batches: %d (%d chat-days skipped)", len(p.added), p.skipped),
+ "dates: " + dateRange,
+ "cutoff: " + cutoff,
+ "owner: " + owner,
+ fmt.Sprintf("bytes: %d (new batch text)", p.bytes),
+ fmt.Sprintf("messages: %d (merged log)", p.messages),
+ fmt.Sprintf("estimated calls: %.0f (about 1.3 per message, limit %d)", estimate, rebuildCallLimit),
+ }
+
+ _, err = io.WriteString(out, strings.Join(lines, "\n")+"\n")
+ if err != nil {
+ return fmt.Errorf("write import plan: %w", err)
+ }
+
+ return nil
+}
+
+// whatsappImport runs the offline import command. It holds the same token lock
+// as the daemon and probe, even for a plan. The plan reads no credentials for a
+// model and makes no network call. Only a completed rebuild replaces the agent
+// directory. Unfinished subagents and pending target deliveries prevent either
+// mode.
+func whatsappImport(ctx context.Context, args []string, out io.Writer) (err error) {
+ options, err := whatsappImportParse(args)
+ if err != nil {
+ return fmt.Errorf("parse import arguments: %w", err)
}
dir, err := dataDir()
}()
root := filepath.Join(dir, "agent")
- err = rebuildIdle(root)
+ agent, err := whatsappImportAgent(root, options.agent)
+ if err != nil {
+ return fmt.Errorf("select import agent: %w", err)
+ }
+
+ err = whatsappImportIdle(root, agent)
if err != nil {
return fmt.Errorf("check import work: %w", err)
}
- old, err := rebuildReadLog(ctx, root)
+ old, err := rebuildReadLog(ctx, agent.dir)
if err != nil {
return fmt.Errorf("read import log: %w", err)
}
+ var rootLog []DatedMessage
+
+ if agent.dir != root {
+ rootLog, err = rebuildReadLog(ctx, root)
+ if err != nil {
+ return fmt.Errorf("read root import history: %w", err)
+ }
+ }
+
names, owner, err := whatsappImportNames(ctx, dir)
if err != nil {
return fmt.Errorf("read import names: %w", err)
}
- plan, err := whatsappImportPlan(ctx, args, old, nil, names, owner, time.Local)
+ plan, err := whatsappImportPlan(ctx, options.paths, old, rootLog, names, owner, time.Local)
if err != nil {
return fmt.Errorf("plan import: %w", err)
}
+ plan.agent = agent.config.Name
err = plan.Write(out)
if err != nil {
return fmt.Errorf("write import plan: %w", err)
}
- if planOnly || len(plan.added) == 0 {
+ if options.planOnly || len(plan.added) == 0 {
return nil
}
// An existing agent is required so that a failed import never creates
// or changes the original agent's configuration.
- _, err = os.Stat(filepath.Join(root, "config.json"))
+ _, err = os.Stat(filepath.Join(agent.dir, "config.json"))
if err != nil {
return fmt.Errorf("read agent config: %w", err)
}
- agent, err := openAgent(root)
- if err != nil {
- return fmt.Errorf("open import agent: %w", err)
- }
-
config, err := loadConfig(dir)
if err != nil {
return fmt.Errorf("load import configuration: %w", err)
}
client := newModelClient(address, auth, agent.CacheKey(), usagePath)
- backup, err := rebuild(ctx, agent, plan.added, client, newCompactor(client, agent.Prompt, config.Compactor, agent.Tools()), out)
+ backup, err := rebuild(ctx, agent, plan.added, client, newCompactor(client, agent.Prompt, config.Compactor, agent.Tools(), agent.Images()), out)
if err != nil {
return fmt.Errorf("rebuild imported memory: %w", err)
}
return nil
}
-// WhatsAppImportPlan holds the new chat-day batches and the size of the merged
-// log. Its estimate uses the measured cost of 1.3 calls per message, not a
-// promise about a particular export or compaction retry.
-type WhatsAppImportPlan struct {
- cutoffs map[string]time.Time
- added []DatedMessage
- chats []string
- cutoff time.Time
- first time.Time
- last time.Time
- bytes int
- messages int
- skipped int
- ownerKnown bool
+// whatsappImportParse accepts GNU-style flags, including --agent=NAME and --
+// before paths that start with a dash.
+func whatsappImportParse(args []string) (WhatsAppImportOptions, error) {
+ var options WhatsAppImportOptions
+
+ flags := true
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ switch {
+ case flags && arg == "--":
+ flags = false
+ case flags && arg == "-n":
+ options.planOnly = true
+ case flags && (arg == "--agent" || strings.HasPrefix(arg, "--agent=")):
+ _, name, found := strings.Cut(arg, "=")
+ if !found {
+ i++
+ if i == len(args) {
+ return options, errors.New("--agent needs a child name")
+ }
+
+ name = args[i]
+ }
+
+ if name == "" {
+ return options, errors.New("--agent needs a child name")
+ }
+
+ options.agent = name
+ case flags && strings.HasPrefix(arg, "-"):
+ return options, fmt.Errorf("unknown import flag %q", arg)
+ default:
+ options.paths = append(options.paths, arg)
+ }
+ }
+
+ if len(options.paths) == 0 {
+ return options, errors.New("usage: onechat import [-n] [--agent NAME] ZIP [ZIP...]")
+ }
+
+ return options, nil
}
-// Write prints the plan without chat text or the owner's profile name.
-func (p WhatsAppImportPlan) Write(out io.Writer) error {
- cutoff := "none (no live WhatsApp batch)"
- if !p.cutoff.IsZero() {
- cutoff = p.cutoff.Format(time.RFC3339)
+// whatsappImportAgent selects the root or an existing direct child without
+// creating a directory or config. A root without a config can have a plan,
+// using the default name.
+func whatsappImportAgent(root, name string) (*Agent, error) {
+ _, err := os.Stat(filepath.Join(root, "config.json"))
+ if errors.Is(err, os.ErrNotExist) && name == "" {
+ var config AgentConfig
+
+ config, err = defaultAgentConfig()
+ if err != nil {
+ return nil, fmt.Errorf("read default import agent: %w", err)
+ }
+
+ return &Agent{
+ dir: root,
+ config: config,
+ }, nil
}
- dateRange := "none"
- if !p.first.IsZero() {
- dateRange = p.first.Format(time.RFC3339) + " to " + p.last.Format(time.RFC3339)
+ if err != nil {
+ return nil, fmt.Errorf("read root agent config: %w", err)
}
- owner := "known (mapped to You)"
- if !p.ownerKnown {
- owner = "unknown (sender names kept as written)"
+ agent, err := openAgent(root)
+ if err != nil {
+ return nil, fmt.Errorf("open root agent: %w", err)
}
- _, err := fmt.Fprintf(out, "chats: %d\n", len(p.chats))
+ if name == "" {
+ return agent, nil
+ }
+
+ if agent.config.Parent != "" {
+ return nil, errors.New("import root cannot have a parent")
+ }
+
+ children, err := agent.Children()
if err != nil {
- return fmt.Errorf("write import plan: %w", err)
+ return nil, fmt.Errorf("read import children: %w", err)
}
- for _, chat := range p.chats {
- line := " " + chat
- cutoff, found := p.cutoffs[chat]
- if found {
- line += " (cutoff: " + cutoff.Format(time.RFC3339) + ")"
+ for _, child := range children {
+ if child.config.Name == name {
+ return child, nil
}
+ }
- _, err = fmt.Fprintln(out, line)
- if err != nil {
- return fmt.Errorf("write import plan: %w", err)
- }
+ return nil, fmt.Errorf("child %q does not exist", name)
+}
+
+// whatsappImportIdle refuses unfinished subagents and pending deliveries to the
+// target. Rebuilding renumbers its log, so saved delivery IDs must not refer to
+// it until their turns finish. The caller holds the token lock.
+func whatsappImportIdle(root string, agent *Agent) error {
+ err := rebuildIdle(agent.dir)
+ if err != nil {
+ return fmt.Errorf("check target work: %w", err)
}
- estimate := 1.3 * float64(p.messages)
- if len(p.added) == 0 {
- estimate = 0
+ data, err := os.ReadFile(filepath.Join(root, "messages.json"))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
}
- lines := []string{
- fmt.Sprintf("batches: %d (%d chat-days skipped)", len(p.added), p.skipped),
- "dates: " + dateRange,
- "cutoff: " + cutoff,
- "owner: " + owner,
- fmt.Sprintf("bytes: %d (new batch text)", p.bytes),
- fmt.Sprintf("messages: %d (merged log)", p.messages),
- fmt.Sprintf("estimated calls: %.0f (about 1.3 per message, limit %d)", estimate, rebuildCallLimit),
+ if err != nil {
+ return fmt.Errorf("read agent deliveries: %w", err)
}
- _, err = io.WriteString(out, strings.Join(lines, "\n")+"\n")
+ var queue DaemonMessageFile
+
+ err = json.Unmarshal(data, &queue)
if err != nil {
- return fmt.Errorf("write import plan: %w", err)
+ return fmt.Errorf("decode agent deliveries: %w", err)
+ }
+
+ for _, message := range queue.Pending {
+ if message.Target == agent.config.Name {
+ return fmt.Errorf("import refused: agent %q has pending deliveries", agent.config.Name)
+ }
}
return nil